diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/urlbar | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/urlbar')
563 files changed, 162139 insertions, 0 deletions
diff --git a/browser/components/urlbar/.eslintrc.js b/browser/components/urlbar/.eslintrc.js new file mode 100644 index 0000000000..8ead689bcc --- /dev/null +++ b/browser/components/urlbar/.eslintrc.js @@ -0,0 +1,14 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], + + rules: { + "mozilla/var-only-at-top-level": "error", + "no-unused-expressions": "error", + }, +}; diff --git a/browser/components/urlbar/MerinoClient.sys.mjs b/browser/components/urlbar/MerinoClient.sys.mjs new file mode 100644 index 0000000000..1e818a78a6 --- /dev/null +++ b/browser/components/urlbar/MerinoClient.sys.mjs @@ -0,0 +1,400 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const SEARCH_PARAMS = { + CLIENT_VARIANTS: "client_variants", + PROVIDERS: "providers", + QUERY: "q", + SEQUENCE_NUMBER: "seq", + SESSION_ID: "sid", +}; + +const SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE"; + +/** + * Client class for querying the Merino server. Each instance maintains its own + * session state including a session ID and sequence number that is included in + * its requests to Merino. + */ +export class MerinoClient { + /** + * @returns {object} + * The names of URL search params. + */ + static get SEARCH_PARAMS() { + return { ...SEARCH_PARAMS }; + } + + /** + * @param {string} name + * An optional name for the client. It will be included in log messages. + */ + constructor(name = "anonymous") { + this.#name = name; + XPCOMUtils.defineLazyGetter(this, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: `MerinoClient [${name}]` }) + ); + } + + /** + * @returns {string} + * The name of the client. + */ + get name() { + return this.#name; + } + + /** + * @returns {number} + * If `resetSession()` is not called within this timeout period after a + * session starts, the session will time out and the next fetch will begin a + * new session. + */ + get sessionTimeoutMs() { + return this.#sessionTimeoutMs; + } + set sessionTimeoutMs(value) { + this.#sessionTimeoutMs = value; + } + + /** + * @returns {number} + * The current session ID. Null when there is no active session. + */ + get sessionID() { + return this.#sessionID; + } + + /** + * @returns {number} + * The current sequence number in the current session. Zero when there is no + * active session. + */ + get sequenceNumber() { + return this.#sequenceNumber; + } + + /** + * @returns {string} + * A string that indicates the status of the last fetch. The values are the + * same as the labels used in the `FX_URLBAR_MERINO_RESPONSE` histogram: + * success, timeout, network_error, http_error + */ + get lastFetchStatus() { + return this.#lastFetchStatus; + } + + /** + * Fetches Merino suggestions. + * + * @param {object} options + * Options object + * @param {string} options.query + * The search string. + * @param {Array} options.providers + * Array of provider names to request from Merino. If this is given it will + * override the `merinoProviders` Nimbus variable and its fallback pref + * `browser.urlbar.merino.providers`. + * @param {number} options.timeoutMs + * Timeout in milliseconds. This method will return once the timeout + * elapses, a response is received, or an error occurs, whichever happens + * first. + * @param {string} options.extraLatencyHistogram + * If specified, the fetch's latency will be recorded in this histogram in + * addition to the usual Merino latency histogram. + * @param {string} options.extraResponseHistogram + * If specified, the fetch's response will be recorded in this histogram in + * addition to the usual Merino response histogram. + * @returns {Array} + * The Merino suggestions or null if there's an error or unexpected + * response. + */ + async fetch({ + query, + providers = null, + timeoutMs = lazy.UrlbarPrefs.get("merinoTimeoutMs"), + extraLatencyHistogram = null, + extraResponseHistogram = null, + }) { + this.logger.info(`Fetch starting with query: "${query}"`); + + // Set up the Merino session ID and related state. The session ID is a UUID + // without leading and trailing braces. + if (!this.#sessionID) { + let uuid = Services.uuid.generateUUID().toString(); + this.#sessionID = uuid.substring(1, uuid.length - 1); + this.#sequenceNumber = 0; + this.#sessionTimer?.cancel(); + + // Per spec, for the user's privacy, the session should time out and a new + // session ID should be used if the engagement does not end soon. + this.#sessionTimer = new lazy.SkippableTimer({ + name: "Merino session timeout", + time: this.#sessionTimeoutMs, + logger: this.logger, + callback: () => this.resetSession(), + }); + } + + // Get the endpoint URL. It's empty by default when running tests so they + // don't hit the network. + let endpointString = lazy.UrlbarPrefs.get("merinoEndpointURL"); + if (!endpointString) { + return []; + } + let url; + try { + url = new URL(endpointString); + } catch (error) { + this.logger.error("Error creating endpoint URL: " + error); + return []; + } + url.searchParams.set(SEARCH_PARAMS.QUERY, query); + url.searchParams.set(SEARCH_PARAMS.SESSION_ID, this.#sessionID); + url.searchParams.set(SEARCH_PARAMS.SEQUENCE_NUMBER, this.#sequenceNumber); + this.#sequenceNumber++; + + let clientVariants = lazy.UrlbarPrefs.get("merinoClientVariants"); + if (clientVariants) { + url.searchParams.set(SEARCH_PARAMS.CLIENT_VARIANTS, clientVariants); + } + + let providersString; + if (providers != null) { + if (!Array.isArray(providers)) { + throw new Error("providers must be an array if given"); + } + providersString = providers.join(","); + } else { + let value = lazy.UrlbarPrefs.get("merinoProviders"); + if (value) { + // The Nimbus variable/pref is used only if it's a non-empty string. + providersString = value; + } + } + + // An empty providers string is a valid value and means Merino should + // receive the request but not return any suggestions, so do not do a simple + // `if (providersString)` here. + if (typeof providersString == "string") { + url.searchParams.set(SEARCH_PARAMS.PROVIDERS, providersString); + } + + let details = { query, providers, timeoutMs, url }; + this.logger.debug("Fetch details: " + JSON.stringify(details)); + + let recordResponse = category => { + this.logger.info("Fetch done with status: " + category); + Services.telemetry.getHistogramById(HISTOGRAM_RESPONSE).add(category); + if (extraResponseHistogram) { + Services.telemetry + .getHistogramById(extraResponseHistogram) + .add(category); + } + this.#lastFetchStatus = category; + recordResponse = null; + }; + + // Set up the timeout timer. + let timer = (this.#timeoutTimer = new lazy.SkippableTimer({ + name: "Merino timeout", + time: timeoutMs, + logger: this.logger, + callback: () => { + // The fetch timed out. + this.logger.info(`Fetch timed out (timeout = ${timeoutMs}ms)`); + recordResponse?.("timeout"); + }, + })); + + // If there's an ongoing fetch, abort it so there's only one at a time. By + // design we do not abort fetches on timeout or when the query is canceled + // so we can record their latency. + try { + this.#fetchController?.abort(); + } catch (error) { + this.logger.error("Error aborting previous fetch: " + error); + } + + // Do the fetch. + let response; + let controller = (this.#fetchController = new AbortController()); + let stopwatchInstance = (this.#latencyStopwatchInstance = {}); + TelemetryStopwatch.start(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.start(extraLatencyHistogram, stopwatchInstance); + } + await Promise.race([ + timer.promise, + (async () => { + try { + // Canceling the timer below resolves its promise, which can resolve + // the outer promise created by `Promise.race`. This inner async + // function happens not to await anything after canceling the timer, + // but if it did, `timer.promise` could win the race and resolve the + // outer promise without a value. For that reason, we declare + // `response` in the outer scope and set it here instead of returning + // the response from this inner function and assuming it will also be + // returned by `Promise.race`. + response = await fetch(url, { signal: controller.signal }); + TelemetryStopwatch.finish(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.finish(extraLatencyHistogram, stopwatchInstance); + } + this.logger.debug( + "Got response: " + + JSON.stringify({ "response.status": response.status, ...details }) + ); + if (!response.ok) { + recordResponse?.("http_error"); + } + } catch (error) { + TelemetryStopwatch.cancel(HISTOGRAM_LATENCY, stopwatchInstance); + if (extraLatencyHistogram) { + TelemetryStopwatch.cancel(extraLatencyHistogram, stopwatchInstance); + } + if (error.name != "AbortError") { + this.logger.error("Fetch error: " + error); + recordResponse?.("network_error"); + } + } finally { + // Now that the fetch is done, cancel the timeout timer so it doesn't + // fire and record a timeout. If it already fired, which it would have + // on timeout, or was already canceled, this is a no-op. + timer.cancel(); + if (controller == this.#fetchController) { + this.#fetchController = null; + } + this.#nextResponseDeferred?.resolve(response); + this.#nextResponseDeferred = null; + } + })(), + ]); + if (timer == this.#timeoutTimer) { + this.#timeoutTimer = null; + } + + // Get the response body as an object. + let body; + try { + body = await response?.json(); + } catch (error) { + this.logger.error("Error getting response as JSON: " + error); + } + + if (body) { + this.logger.debug("Response body: " + JSON.stringify(body)); + } + + if (!body?.suggestions?.length) { + recordResponse?.("no_suggestion"); + return []; + } + + let { suggestions, request_id } = body; + if (!Array.isArray(suggestions)) { + this.logger.error("Unexpected response: " + JSON.stringify(body)); + recordResponse?.("no_suggestion"); + return []; + } + + recordResponse?.("success"); + return suggestions.map(suggestion => ({ + ...suggestion, + request_id, + source: "merino", + })); + } + + /** + * Resets the Merino session ID and related state. + */ + resetSession() { + this.#sessionID = null; + this.#sequenceNumber = 0; + this.#sessionTimer?.cancel(); + this.#sessionTimer = null; + this.#nextSessionResetDeferred?.resolve(); + this.#nextSessionResetDeferred = null; + } + + /** + * Cancels the timeout timer. + */ + cancelTimeoutTimer() { + this.#timeoutTimer?.cancel(); + } + + /** + * Returns a promise that's resolved when the next response is received or a + * network error occurs. + * + * @returns {Promise} + * The promise is resolved with the `Response` object or undefined if a + * network error occurred. + */ + waitForNextResponse() { + if (!this.#nextResponseDeferred) { + this.#nextResponseDeferred = lazy.PromiseUtils.defer(); + } + return this.#nextResponseDeferred.promise; + } + + /** + * Returns a promise that's resolved when the session is next reset, including + * on session timeout. + * + * @returns {Promise} + */ + waitForNextSessionReset() { + if (!this.#nextSessionResetDeferred) { + this.#nextSessionResetDeferred = lazy.PromiseUtils.defer(); + } + return this.#nextSessionResetDeferred.promise; + } + + get _test_sessionTimer() { + return this.#sessionTimer; + } + + get _test_timeoutTimer() { + return this.#timeoutTimer; + } + + get _test_fetchController() { + return this.#fetchController; + } + + get _test_latencyStopwatchInstance() { + return this.#latencyStopwatchInstance; + } + + // State related to the current session. + #sessionID = null; + #sequenceNumber = 0; + #sessionTimer = null; + #sessionTimeoutMs = SESSION_TIMEOUT_MS; + + #name; + #timeoutTimer = null; + #fetchController = null; + #latencyStopwatchInstance = null; + #lastFetchStatus = null; + #nextResponseDeferred = null; + #nextSessionResetDeferred = null; +} diff --git a/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs b/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs new file mode 100644 index 0000000000..9752c878e5 --- /dev/null +++ b/browser/components/urlbar/QuickActionsLoaderDefault.sys.mjs @@ -0,0 +1,314 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +if (AppConstants.MOZ_UPDATER) { + XPCOMUtils.defineLazyServiceGetter( + lazy, + "AUS", + "@mozilla.org/updates/update-service;1", + "nsIApplicationUpdateService" + ); +} + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SCREENSHOT_BROWSER_COMPONENT", + "screenshots.browser.component.enabled", + false +); + +let openUrlFun = url => () => openUrl(url); +let openUrl = url => { + let window = lazy.BrowserWindowTracker.getTopWindow(); + + if (url.startsWith("about:")) { + window.switchToTabHavingURI(Services.io.newURI(url), true, { + ignoreFragment: "whenComparing", + }); + } else { + window.gBrowser.addTab(url, { + inBackground: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + } + return { focusContent: true }; +}; + +let openAddonsUrl = url => { + return () => { + let window = lazy.BrowserWindowTracker.getTopWindow(); + window.BrowserOpenAddonsMgr(url, { selectTabByViewId: true }); + }; +}; + +let currentBrowser = () => + lazy.BrowserWindowTracker.getTopWindow()?.gBrowser.selectedBrowser; +let currentTab = () => + lazy.BrowserWindowTracker.getTopWindow()?.gBrowser.selectedTab; + +XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization(["branding/brand.ftl", "browser/browser.ftl"], true); +}); + +const DEFAULT_ACTIONS = { + addons: { + l10nCommands: ["quickactions-cmd-addons2", "quickactions-addons"], + icon: "chrome://mozapps/skin/extensions/category-extensions.svg", + label: "quickactions-addons", + onPick: openAddonsUrl("addons://discover/"), + }, + bookmarks: { + l10nCommands: ["quickactions-cmd-bookmarks", "quickactions-bookmarks2"], + icon: "chrome://browser/skin/bookmark.svg", + label: "quickactions-bookmarks2", + onPick: () => { + lazy.BrowserWindowTracker.getTopWindow().top.PlacesCommandHook.showPlacesOrganizer( + "BookmarksToolbar" + ); + }, + }, + clear: { + l10nCommands: [ + "quickactions-cmd-clearhistory", + "quickactions-clearhistory", + ], + label: "quickactions-clearhistory", + onPick: () => { + lazy.BrowserWindowTracker.getTopWindow() + .document.getElementById("Tools:Sanitize") + .doCommand(); + }, + }, + downloads: { + l10nCommands: ["quickactions-cmd-downloads", "quickactions-downloads2"], + icon: "chrome://browser/skin/downloads/downloads.svg", + label: "quickactions-downloads2", + onPick: openUrlFun("about:downloads"), + }, + extensions: { + l10nCommands: ["quickactions-cmd-extensions", "quickactions-extensions"], + icon: "chrome://mozapps/skin/extensions/category-extensions.svg", + label: "quickactions-extensions", + onPick: openAddonsUrl("addons://list/extension"), + }, + inspect: { + l10nCommands: ["quickactions-cmd-inspector", "quickactions-inspector2"], + icon: "chrome://devtools/skin/images/open-inspector.svg", + label: "quickactions-inspector2", + isVisible: () => + lazy.DevToolsShim.isEnabled() || lazy.DevToolsShim.isDevToolsUser(), + isActive: () => { + // The inspect action is available if: + // 1. DevTools is enabled. + // 2. The user can be considered as a DevTools user. + // 3. The url is not about:devtools-toolbox. + // 4. The inspector is not opened yet on the page. + return ( + lazy.DevToolsShim.isEnabled() && + lazy.DevToolsShim.isDevToolsUser() && + !currentBrowser()?.currentURI.spec.startsWith( + "about:devtools-toolbox" + ) && + !lazy.DevToolsShim.hasToolboxForTab(currentTab()) + ); + }, + onPick: openInspector, + }, + logins: { + l10nCommands: ["quickactions-cmd-logins", "quickactions-logins2"], + label: "quickactions-logins2", + onPick: openUrlFun("about:logins"), + }, + plugins: { + l10nCommands: ["quickactions-cmd-plugins", "quickactions-plugins"], + icon: "chrome://mozapps/skin/extensions/category-extensions.svg", + label: "quickactions-plugins", + onPick: openAddonsUrl("addons://list/plugin"), + }, + print: { + l10nCommands: ["quickactions-cmd-print", "quickactions-print2"], + label: "quickactions-print2", + icon: "chrome://global/skin/icons/print.svg", + onPick: () => { + lazy.BrowserWindowTracker.getTopWindow() + .document.getElementById("cmd_print") + .doCommand(); + }, + }, + private: { + l10nCommands: ["quickactions-cmd-private", "quickactions-private2"], + label: "quickactions-private2", + icon: "chrome://global/skin/icons/indicator-private-browsing.svg", + onPick: () => { + lazy.BrowserWindowTracker.getTopWindow().OpenBrowserWindow({ + private: true, + }); + }, + }, + refresh: { + l10nCommands: ["quickactions-cmd-refresh", "quickactions-refresh"], + label: "quickactions-refresh", + onPick: () => { + lazy.ResetProfile.openConfirmationDialog( + lazy.BrowserWindowTracker.getTopWindow() + ); + }, + }, + restart: { + l10nCommands: ["quickactions-cmd-restart", "quickactions-restart"], + label: "quickactions-restart", + onPick: restartBrowser, + }, + screenshot: { + l10nCommands: ["quickactions-cmd-screenshot", "quickactions-screenshot3"], + label: "quickactions-screenshot3", + icon: "chrome://browser/skin/screenshot.svg", + isActive: () => { + return !lazy.BrowserWindowTracker.getTopWindow().gScreenshots.shouldScreenshotsButtonBeDisabled(); + }, + onPick: () => { + if (lazy.SCREENSHOT_BROWSER_COMPONENT) { + Services.obs.notifyObservers( + lazy.BrowserWindowTracker.getTopWindow(), + "menuitem-screenshot", + "quick_actions" + ); + } else { + Services.obs.notifyObservers( + null, + "menuitem-screenshot-extension", + "quickaction" + ); + } + return { focusContent: true }; + }, + }, + settings: { + l10nCommands: ["quickactions-cmd-settings", "quickactions-settings2"], + icon: "chrome://global/skin/icons/settings.svg", + label: "quickactions-settings2", + onPick: openUrlFun("about:preferences"), + }, + themes: { + l10nCommands: ["quickactions-cmd-themes", "quickactions-themes"], + icon: "chrome://mozapps/skin/extensions/category-extensions.svg", + label: "quickactions-themes", + onPick: openAddonsUrl("addons://list/theme"), + }, + update: { + l10nCommands: ["quickactions-cmd-update", "quickactions-update"], + label: "quickactions-update", + isActive: () => { + if (!AppConstants.MOZ_UPDATER) { + return false; + } + return ( + lazy.AUS.currentState == Ci.nsIApplicationUpdateService.STATE_PENDING + ); + }, + onPick: restartBrowser, + }, + viewsource: { + l10nCommands: ["quickactions-cmd-viewsource", "quickactions-viewsource2"], + icon: "chrome://global/skin/icons/settings.svg", + label: "quickactions-viewsource2", + isActive: () => currentBrowser()?.currentURI.scheme !== "view-source", + onPick: () => openUrl("view-source:" + currentBrowser().currentURI.spec), + }, +}; + +function openInspector() { + lazy.DevToolsShim.showToolboxForTab( + lazy.BrowserWindowTracker.getTopWindow().gBrowser.selectedTab, + { toolId: "inspector" } + ); +} + +// TODO: We likely want a prompt to confirm with the user that they want to restart +// the browser. +function restartBrowser() { + // Notify all windows that an application quit has been requested. + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + // Something aborted the quit process. + if (cancelQuit.data) { + return; + } + // If already in safe mode restart in safe mode. + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } else { + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + } +} + +function random(seed) { + let x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +function shuffle(array, seed) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(random(seed) * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +/** + * Loads the default QuickActions. + */ +export class QuickActionsLoaderDefault { + static async load() { + let keys = Object.keys(DEFAULT_ACTIONS); + if (lazy.UrlbarPrefs.get("quickactions.randomOrderActions")) { + // We insert the actions in a random order which means they will be returned + // in a random but consistent order (the order of results for "view" and "views" + // should be the same). + // We use the Nimbus randomizationId as the seed as the order should not change + // for the user between restarts, it should be random between users but a user should + // see actions the same order. + let seed = [...lazy.ClientEnvironment.randomizationId] + .map(x => x.charCodeAt(0)) + .reduce((sum, a) => sum + a, 0); + shuffle(keys, seed); + } + for (const key of keys) { + let actionData = DEFAULT_ACTIONS[key]; + let messages = await lazy.gFluentStrings.formatMessages( + actionData.l10nCommands.map(id => ({ id })) + ); + actionData.commands = messages + .map(({ value }) => value.split(",").map(x => x.trim().toLowerCase())) + .flat(); + lazy.UrlbarProviderQuickActions.addAction(key, actionData); + } + } +} diff --git a/browser/components/urlbar/QuickSuggest.sys.mjs b/browser/components/urlbar/QuickSuggest.sys.mjs new file mode 100644 index 0000000000..743814627b --- /dev/null +++ b/browser/components/urlbar/QuickSuggest.sys.mjs @@ -0,0 +1,481 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +// Quick suggest features. On init, QuickSuggest creates an instance of each and +// keeps it in the `#features` map. See `BaseFeature`. +const FEATURES = { + AddonSuggestions: + "resource:///modules/urlbar/private/AddonSuggestions.sys.mjs", + AdmWikipedia: "resource:///modules/urlbar/private/AdmWikipedia.sys.mjs", + BlockedSuggestions: + "resource:///modules/urlbar/private/BlockedSuggestions.sys.mjs", + ImpressionCaps: "resource:///modules/urlbar/private/ImpressionCaps.sys.mjs", + Weather: "resource:///modules/urlbar/private/Weather.sys.mjs", +}; + +const TIMESTAMP_TEMPLATE = "%YYYYMMDDHH%"; +const TIMESTAMP_LENGTH = 10; +const TIMESTAMP_REGEXP = /^\d{10}$/; + +const TELEMETRY_EVENT_CATEGORY = "contextservices.quicksuggest"; + +// Values returned by the onboarding dialog depending on the user's response. +// These values are used in telemetry events, so be careful about changing them. +const ONBOARDING_CHOICE = { + ACCEPT_2: "accept_2", + CLOSE_1: "close_1", + DISMISS_1: "dismiss_1", + DISMISS_2: "dismiss_2", + LEARN_MORE_1: "learn_more_1", + LEARN_MORE_2: "learn_more_2", + NOT_NOW_2: "not_now_2", + REJECT_2: "reject_2", +}; + +const ONBOARDING_URI = + "chrome://browser/content/urlbar/quicksuggestOnboarding.html"; + +/** + * This class manages the quick suggest feature (a.k.a Firefox Suggest) and has + * related helpers. + */ +class _QuickSuggest { + /** + * @returns {string} + * The name of the quick suggest telemetry event category. + */ + get TELEMETRY_EVENT_CATEGORY() { + return TELEMETRY_EVENT_CATEGORY; + } + + /** + * @returns {string} + * The timestamp template string used in quick suggest URLs. + */ + get TIMESTAMP_TEMPLATE() { + return TIMESTAMP_TEMPLATE; + } + + /** + * @returns {number} + * The length of the timestamp in quick suggest URLs. + */ + get TIMESTAMP_LENGTH() { + return TIMESTAMP_LENGTH; + } + + /** + * @returns {string} + * The help URL for the Quick Suggest feature. + */ + get HELP_URL() { + return ( + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "firefox-suggest" + ); + } + + get ONBOARDING_CHOICE() { + return { ...ONBOARDING_CHOICE }; + } + + get ONBOARDING_URI() { + return ONBOARDING_URI; + } + + /** + * @returns {BlockedSuggestions} + * The blocked suggestions feature. + */ + get blockedSuggestions() { + return this.#features.BlockedSuggestions; + } + + /** + * @returns {ImpressionCaps} + * The impression caps feature. + */ + get impressionCaps() { + return this.#features.ImpressionCaps; + } + + /** + * @returns {Weather} + * A feature that periodically fetches weather suggestions from Merino. + */ + get weather() { + return this.#features.Weather; + } + + get logger() { + if (!this._logger) { + this._logger = lazy.UrlbarUtils.getLogger({ prefix: "QuickSuggest" }); + } + return this._logger; + } + + /** + * Initializes the quick suggest feature. This must be called before using + * quick suggest. It's safe to call more than once. + */ + init() { + if (Object.keys(this.#features).length) { + // Already initialized. + return; + } + + // Create an instance of each feature and keep it in `#features`. + for (let [name, uri] of Object.entries(FEATURES)) { + let { [name]: ctor } = ChromeUtils.importESModule(uri); + let feature = new ctor(); + this.#features[name] = feature; + + // Update the map from enabling preferences to features. + let prefs = feature.enablingPreferences; + if (prefs) { + for (let p of prefs) { + let features = this.#featuresByEnablingPrefs.get(p); + if (!features) { + features = new Set(); + this.#featuresByEnablingPrefs.set(p, features); + } + features.add(feature); + } + } + } + + this._updateFeatureState(); + lazy.NimbusFeatures.urlbar.onUpdate(() => this._updateFeatureState()); + lazy.UrlbarPrefs.addObserver(this); + } + + /** + * Returns a quick suggest feature by name. + * + * @param {string} name + * The name of the feature's JS class. + * @returns {BaseFeature} + * The feature object, an instance of a subclass of `BaseFeature`. + */ + getFeature(name) { + return this.#features[name]; + } + + /** + * Called when a urlbar pref changes. + * + * @param {string} pref + * The name of the pref relative to `browser.urlbar`. + */ + onPrefChanged(pref) { + // If any feature's enabling preference changed, update it now. + let features = this.#featuresByEnablingPrefs.get(pref); + if (features) { + for (let f of features) { + f.update(); + } + } + + switch (pref) { + case "quicksuggest.dataCollection.enabled": + if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) { + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + "data_collect_toggled", + lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled" + ); + } + break; + case "suggest.quicksuggest.nonsponsored": + if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) { + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + "enable_toggled", + lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled" + ); + } + break; + case "suggest.quicksuggest.sponsored": + if (!lazy.UrlbarPrefs.updatingFirefoxSuggestScenario) { + Services.telemetry.recordEvent( + TELEMETRY_EVENT_CATEGORY, + "sponsored_toggled", + lazy.UrlbarPrefs.get(pref) ? "enabled" : "disabled" + ); + } + break; + } + } + + /** + * Returns whether a given URL and quick suggest's URL are equivalent. URLs + * are equivalent if they are identical except for substrings that replaced + * templates in the original suggestion URL. + * + * For example, a suggestion URL from the backing suggestions source might + * include a timestamp template "%YYYYMMDDHH%" like this: + * + * http://example.com/foo?bar=%YYYYMMDDHH% + * + * When a quick suggest result is created from this suggestion URL, it's + * created with a URL that is a copy of the suggestion URL but with the + * template replaced with a real timestamp value, like this: + * + * http://example.com/foo?bar=2021111610 + * + * All URLs created from this single suggestion URL are considered equivalent + * regardless of their real timestamp values. + * + * @param {string} url + * The URL to check. + * @param {UrlbarResult} result + * The quick suggest result. Will compare {@link url} to `result.payload.url` + * @returns {boolean} + * Whether `url` is equivalent to `result.payload.url`. + */ + isURLEquivalentToResultURL(url, result) { + // If the URLs aren't the same length, they can't be equivalent. + let resultURL = result.payload.url; + if (resultURL.length != url.length) { + return false; + } + + // If the result URL doesn't have a timestamp, then do a straight string + // comparison. + let { urlTimestampIndex } = result.payload; + if (typeof urlTimestampIndex != "number" || urlTimestampIndex < 0) { + return resultURL == url; + } + + // Compare the first parts of the strings before the timestamps. + if ( + resultURL.substring(0, urlTimestampIndex) != + url.substring(0, urlTimestampIndex) + ) { + return false; + } + + // Compare the second parts of the strings after the timestamps. + let remainderIndex = urlTimestampIndex + TIMESTAMP_LENGTH; + if (resultURL.substring(remainderIndex) != url.substring(remainderIndex)) { + return false; + } + + // Test the timestamp against the regexp. + let maybeTimestamp = url.substring( + urlTimestampIndex, + urlTimestampIndex + TIMESTAMP_LENGTH + ); + return TIMESTAMP_REGEXP.test(maybeTimestamp); + } + + /** + * Some suggestion properties like `url` and `click_url` include template + * substrings that must be replaced with real values. This method replaces + * templates with appropriate values in place. + * + * @param {object} suggestion + * A suggestion object fetched from remote settings or Merino. + */ + replaceSuggestionTemplates(suggestion) { + let now = new Date(); + let timestampParts = [ + now.getFullYear(), + now.getMonth() + 1, + now.getDate(), + now.getHours(), + ]; + let timestamp = timestampParts + .map(n => n.toString().padStart(2, "0")) + .join(""); + for (let key of ["url", "click_url"]) { + let value = suggestion[key]; + if (!value) { + continue; + } + + let timestampIndex = value.indexOf(TIMESTAMP_TEMPLATE); + if (timestampIndex >= 0) { + if (key == "url") { + suggestion.urlTimestampIndex = timestampIndex; + } + // We could use replace() here but we need the timestamp index for + // `suggestion.urlTimestampIndex`, and since we already have that, avoid + // another O(n) substring search and manually replace the template with + // the timestamp. + suggestion[key] = + value.substring(0, timestampIndex) + + timestamp + + value.substring(timestampIndex + TIMESTAMP_TEMPLATE.length); + } + } + } + + /** + * Records the Nimbus exposure event if it hasn't already been recorded during + * the app session. This method actually queues the recording on idle because + * it's potentially an expensive operation. + */ + ensureExposureEventRecorded() { + // `recordExposureEvent()` makes sure only one event is recorded per app + // session even if it's called many times, but since it may be expensive, we + // also keep `_recordedExposureEvent`. + if (!this._recordedExposureEvent) { + this._recordedExposureEvent = true; + Services.tm.idleDispatchToMainThread(() => + lazy.NimbusFeatures.urlbar.recordExposureEvent({ once: true }) + ); + } + } + + /** + * An onboarding dialog can be shown to the users who are enrolled into + * the QuickSuggest experiments or rollouts. This behavior is controlled + * by the pref `browser.urlbar.quicksuggest.shouldShowOnboardingDialog` + * which can be remotely configured by Nimbus. + * + * Given that the release may overlap with another onboarding dialog, we may + * wait for a few restarts before showing the QuickSuggest dialog. This can + * be remotely configured by Nimbus through + * `quickSuggestShowOnboardingDialogAfterNRestarts`, the default is 0. + * + * @returns {boolean} + * True if the dialog was shown and false if not. + */ + async maybeShowOnboardingDialog() { + // The call to this method races scenario initialization on startup, and the + // Nimbus variables we rely on below depend on the scenario, so wait for it + // to be initialized. + await lazy.UrlbarPrefs.firefoxSuggestScenarioStartupPromise; + + // If the feature is disabled, the user has already seen the dialog, or the + // user has already opted in, don't show the onboarding. + if ( + !lazy.UrlbarPrefs.get("quickSuggestEnabled") || + lazy.UrlbarPrefs.get("quicksuggest.showedOnboardingDialog") || + lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled") + ) { + return false; + } + + // Wait a number of restarts before showing the dialog. + let restartsSeen = lazy.UrlbarPrefs.get("quicksuggest.seenRestarts"); + if ( + restartsSeen < + lazy.UrlbarPrefs.get("quickSuggestShowOnboardingDialogAfterNRestarts") + ) { + lazy.UrlbarPrefs.set("quicksuggest.seenRestarts", restartsSeen + 1); + return false; + } + + let win = lazy.BrowserWindowTracker.getTopWindow(); + + // Don't show the dialog on top of about:welcome for new users. + if (win.gBrowser?.currentURI?.spec == "about:welcome") { + return false; + } + + if (lazy.UrlbarPrefs.get("experimentType") === "modal") { + this.ensureExposureEventRecorded(); + } + + if (!lazy.UrlbarPrefs.get("quickSuggestShouldShowOnboardingDialog")) { + return false; + } + + let variationType; + try { + // An error happens if the pref is not in user prefs. + variationType = lazy.UrlbarPrefs.get( + "quickSuggestOnboardingDialogVariation" + ).toLowerCase(); + } catch (e) {} + + let params = { choice: undefined, variationType, visitedMain: false }; + await win.gDialogBox.open(ONBOARDING_URI, params); + + lazy.UrlbarPrefs.set("quicksuggest.showedOnboardingDialog", true); + lazy.UrlbarPrefs.set( + "quicksuggest.onboardingDialogVersion", + JSON.stringify({ version: 1, variation: variationType }) + ); + + // Record the user's opt-in choice on the user branch. This pref is sticky, + // so it will retain its user-branch value regardless of what the particular + // default was at the time. + let optedIn = params.choice == ONBOARDING_CHOICE.ACCEPT_2; + lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", optedIn); + + switch (params.choice) { + case ONBOARDING_CHOICE.LEARN_MORE_1: + case ONBOARDING_CHOICE.LEARN_MORE_2: + win.openTrustedLinkIn(this.HELP_URL, "tab"); + break; + case ONBOARDING_CHOICE.ACCEPT_2: + case ONBOARDING_CHOICE.REJECT_2: + case ONBOARDING_CHOICE.NOT_NOW_2: + case ONBOARDING_CHOICE.CLOSE_1: + // No other action required. + break; + default: + params.choice = params.visitedMain + ? ONBOARDING_CHOICE.DISMISS_2 + : ONBOARDING_CHOICE.DISMISS_1; + break; + } + + lazy.UrlbarPrefs.set("quicksuggest.onboardingDialogChoice", params.choice); + + Services.telemetry.recordEvent( + "contextservices.quicksuggest", + "opt_in_dialog", + params.choice + ); + + return true; + } + + /** + * Updates state based on whether quick suggest and its features are enabled. + */ + _updateFeatureState() { + // IMPORTANT: This method is a `NimbusFeatures.urlbar.onUpdate()` callback, + // which means it's called on every change to any pref that is a fallback + // for a urlbar Nimbus variable. + + // Update features. + for (let feature of Object.values(this.#features)) { + feature.update(); + } + + // Update state related to quick suggest as a whole. + let enabled = lazy.UrlbarPrefs.get("quickSuggestEnabled"); + Services.telemetry.setEventRecordingEnabled( + TELEMETRY_EVENT_CATEGORY, + enabled + ); + } + + // Maps from quick suggest feature class names to feature instances. + #features = {}; + + // Maps from preference names to the `Set` of feature instances they enable. + #featuresByEnablingPrefs = new Map(); +} + +export const QuickSuggest = new _QuickSuggest(); diff --git a/browser/components/urlbar/UrlbarController.sys.mjs b/browser/components/urlbar/UrlbarController.sys.mjs new file mode 100644 index 0000000000..563a6b7963 --- /dev/null +++ b/browser/components/urlbar/UrlbarController.sys.mjs @@ -0,0 +1,1399 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS"; +const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS"; + +const TELEMETRY_SCALAR_ENGAGEMENT = "urlbar.engagement"; +const TELEMETRY_SCALAR_ABANDONMENT = "urlbar.abandonment"; + +const NOTIFICATIONS = { + QUERY_STARTED: "onQueryStarted", + QUERY_RESULTS: "onQueryResults", + QUERY_RESULT_REMOVED: "onQueryResultRemoved", + QUERY_CANCELLED: "onQueryCancelled", + QUERY_FINISHED: "onQueryFinished", + VIEW_OPEN: "onViewOpen", + VIEW_CLOSE: "onViewClose", +}; + +/** + * The address bar controller handles queries from the address bar, obtains + * results and returns them to the UI for display. + * + * Listeners may be added to listen for the results. They may support the + * following methods which may be called when a query is run: + * + * - onQueryStarted(queryContext) + * - onQueryResults(queryContext) + * - onQueryCancelled(queryContext) + * - onQueryFinished(queryContext) + * - onQueryResultRemoved(index) + * - onViewOpen() + * - onViewClose() + */ +export class UrlbarController { + /** + * Initialises the class. The manager may be overridden here, this is for + * test purposes. + * + * @param {object} options + * The initial options for UrlbarController. + * @param {UrlbarInput} options.input + * The input this controller is operating with. + * @param {object} [options.manager] + * Optional fake providers manager to override the built-in providers manager. + * Intended for use in unit tests only. + */ + constructor(options = {}) { + if (!options.input) { + throw new Error("Missing options: input"); + } + if (!options.input.window) { + throw new Error("input is missing 'window' property."); + } + if ( + !options.input.window.location || + options.input.window.location.href != AppConstants.BROWSER_CHROME_URL + ) { + throw new Error("input.window should be an actual browser window."); + } + if (!("isPrivate" in options.input)) { + throw new Error("input.isPrivate must be set."); + } + + this.input = options.input; + this.browserWindow = options.input.window; + + this.manager = options.manager || lazy.UrlbarProvidersManager; + + this._listeners = new Set(); + this._userSelectionBehavior = "none"; + + this.engagementEvent = new TelemetryEvent( + this, + options.eventTelemetryCategory + ); + + XPCOMUtils.defineLazyGetter(this, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: "Controller" }) + ); + } + + get NOTIFICATIONS() { + return NOTIFICATIONS; + } + + /** + * Hooks up the controller with a view. + * + * @param {UrlbarView} view + * The UrlbarView instance associated with this controller. + */ + setView(view) { + this.view = view; + } + + /** + * Takes a query context and starts the query based on the user input. + * + * @param {UrlbarQueryContext} queryContext The query details. + */ + async startQuery(queryContext) { + // Cancel any running query. + this.cancelQuery(); + + // Wrap the external queryContext, to track a unique object, in case + // the external consumer reuses the same context multiple times. + // This also allows to add properties without polluting the context. + // Note this can't be null-ed or deleted once a query is done, because it's + // used by #dismissSelectedResult and handleKeyNavigation, that can run after + // a query is cancelled or finished. + let contextWrapper = (this._lastQueryContextWrapper = { queryContext }); + + queryContext.lastResultCount = 0; + TelemetryStopwatch.start(TELEMETRY_1ST_RESULT, queryContext); + TelemetryStopwatch.start(TELEMETRY_6_FIRST_RESULTS, queryContext); + + // For proper functionality we must ensure this notification is fired + // synchronously, as soon as startQuery is invoked, but after any + // notifications related to the previous query. + this.notify(NOTIFICATIONS.QUERY_STARTED, queryContext); + await this.manager.startQuery(queryContext, this); + // If the query has been cancelled, onQueryFinished was notified already. + // Note this._lastQueryContextWrapper may have changed in the meanwhile. + if ( + contextWrapper === this._lastQueryContextWrapper && + !contextWrapper.done + ) { + contextWrapper.done = true; + // TODO (Bug 1549936) this is necessary to avoid leaks in PB tests. + this.manager.cancelQuery(queryContext); + this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext); + } + return queryContext; + } + + /** + * Cancels an in-progress query. Note, queries may continue running if they + * can't be cancelled. + */ + cancelQuery() { + // We must clear the pause impression timer in any case, even if the query + // already finished. + this.engagementEvent.clearPauseImpressionTimer(); + + // If the query finished already, don't handle cancel. + if (!this._lastQueryContextWrapper || this._lastQueryContextWrapper.done) { + return; + } + + this._lastQueryContextWrapper.done = true; + + let { queryContext } = this._lastQueryContextWrapper; + TelemetryStopwatch.cancel(TELEMETRY_1ST_RESULT, queryContext); + TelemetryStopwatch.cancel(TELEMETRY_6_FIRST_RESULTS, queryContext); + this.manager.cancelQuery(queryContext); + this.notify(NOTIFICATIONS.QUERY_CANCELLED, queryContext); + this.notify(NOTIFICATIONS.QUERY_FINISHED, queryContext); + } + + /** + * Receives results from a query. + * + * @param {UrlbarQueryContext} queryContext The query details. + */ + receiveResults(queryContext) { + if (queryContext.lastResultCount < 1 && queryContext.results.length >= 1) { + TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT, queryContext); + } + if (queryContext.lastResultCount < 6 && queryContext.results.length >= 6) { + TelemetryStopwatch.finish(TELEMETRY_6_FIRST_RESULTS, queryContext); + } + + this.engagementEvent.startPauseImpressionTimer( + queryContext, + this.input.getSearchSource() + ); + + if (queryContext.firstResultChanged) { + // Notify the input so it can make adjustments based on the first result. + if (this.input.onFirstResult(queryContext.results[0])) { + // The input canceled the query and started a new one. + return; + } + + // The first time we receive results try to connect to the heuristic + // result. + this.speculativeConnect( + queryContext.results[0], + queryContext, + "resultsadded" + ); + } + + this.notify(NOTIFICATIONS.QUERY_RESULTS, queryContext); + // Update lastResultCount after notifying, so the view can use it. + queryContext.lastResultCount = queryContext.results.length; + } + + /** + * Adds a listener for query actions and results. + * + * @param {object} listener The listener to add. + * @throws {TypeError} Throws if the listener is not an object. + */ + addQueryListener(listener) { + if (!listener || typeof listener != "object") { + throw new TypeError("Expected listener to be an object"); + } + this._listeners.add(listener); + } + + /** + * Removes a query listener. + * + * @param {object} listener The listener to add. + */ + removeQueryListener(listener) { + this._listeners.delete(listener); + } + + /** + * Checks whether a keyboard event that would normally open the view should + * instead be handled natively by the input field. + * On certain platforms, the up and down keys can be used to move the caret, + * in which case we only want to open the view if the caret is at the + * start or end of the input. + * + * @param {KeyboardEvent} event + * The DOM KeyboardEvent. + * @returns {boolean} + * Returns true if the event should move the caret instead of opening the + * view. + */ + keyEventMovesCaret(event) { + if (this.view.isOpen) { + return false; + } + if (AppConstants.platform != "macosx" && AppConstants.platform != "linux") { + return false; + } + let isArrowUp = event.keyCode == KeyEvent.DOM_VK_UP; + let isArrowDown = event.keyCode == KeyEvent.DOM_VK_DOWN; + if (!isArrowUp && !isArrowDown) { + return false; + } + let start = this.input.selectionStart; + let end = this.input.selectionEnd; + if ( + end != start || + (isArrowUp && start > 0) || + (isArrowDown && end < this.input.value.length) + ) { + return true; + } + return false; + } + + /** + * Receives keyboard events from the input and handles those that should + * navigate within the view or pick the currently selected item. + * + * @param {KeyboardEvent} event + * The DOM KeyboardEvent. + * @param {boolean} executeAction + * Whether the event should actually execute the associated action, or just + * be managed (at a preventDefault() level). This is used when the event + * will be deferred by the event bufferer, but preventDefault() and friends + * should still happen synchronously. + */ + handleKeyNavigation(event, executeAction = true) { + const isMac = AppConstants.platform == "macosx"; + // Handle readline/emacs-style navigation bindings on Mac. + if ( + isMac && + this.view.isOpen && + event.ctrlKey && + (event.key == "n" || event.key == "p") + ) { + if (executeAction) { + this.view.selectBy(1, { reverse: event.key == "p" }); + } + event.preventDefault(); + return; + } + + if (this.view.isOpen && executeAction && this._lastQueryContextWrapper) { + let { queryContext } = this._lastQueryContextWrapper; + let handled = this.view.oneOffSearchButtons.handleKeyDown( + event, + this.view.visibleRowCount, + this.view.allowEmptySelection, + queryContext.searchString + ); + if (handled) { + return; + } + } + + switch (event.keyCode) { + case KeyEvent.DOM_VK_ESCAPE: + if (executeAction) { + if (this.view.isOpen) { + this.view.close(); + } else { + this.input.handleRevert(true); + } + } + event.preventDefault(); + break; + case KeyEvent.DOM_VK_SPACE: + if (!this.view.shouldSpaceActivateSelectedElement()) { + break; + } + // Fall through, we want the SPACE key to activate this element. + case KeyEvent.DOM_VK_RETURN: + if (executeAction) { + this.input.handleCommand(event); + } + event.preventDefault(); + break; + case KeyEvent.DOM_VK_TAB: + // It's always possible to tab through results when the urlbar was + // focused with the mouse or has a search string, or when the view + // already has a selection. + // We allow tabbing without a search string when in search mode preview, + // since that means the user has interacted with the Urlbar since + // opening it. + // When there's no search string and no view selection, we want to focus + // the next toolbar item instead, for accessibility reasons. + let allowTabbingThroughResults = + this.input.focusedViaMousedown || + this.input.searchMode?.isPreview || + this.view.selectedElement || + (this.input.value && + this.input.getAttribute("pageproxystate") != "valid"); + if ( + // Even if the view is closed, we may be waiting results, and in + // such a case we don't want to tab out of the urlbar. + (this.view.isOpen || !executeAction) && + !event.ctrlKey && + !event.altKey && + allowTabbingThroughResults + ) { + if (executeAction) { + this.userSelectionBehavior = "tab"; + this.view.selectBy(1, { + reverse: event.shiftKey, + userPressedTab: true, + }); + } + event.preventDefault(); + } + break; + case KeyEvent.DOM_VK_PAGE_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + if (event.ctrlKey) { + break; + } + // eslint-disable-next-lined no-fallthrough + case KeyEvent.DOM_VK_DOWN: + case KeyEvent.DOM_VK_UP: + if (event.altKey) { + break; + } + if (this.view.isOpen) { + if (executeAction) { + this.userSelectionBehavior = "arrow"; + this.view.selectBy( + event.keyCode == KeyEvent.DOM_VK_PAGE_DOWN || + event.keyCode == KeyEvent.DOM_VK_PAGE_UP + ? lazy.UrlbarUtils.PAGE_UP_DOWN_DELTA + : 1, + { + reverse: + event.keyCode == KeyEvent.DOM_VK_UP || + event.keyCode == KeyEvent.DOM_VK_PAGE_UP, + } + ); + } + } else { + if (this.keyEventMovesCaret(event)) { + break; + } + if (executeAction) { + this.userSelectionBehavior = "arrow"; + this.input.startQuery({ + searchString: this.input.value, + event, + }); + } + } + event.preventDefault(); + break; + case KeyEvent.DOM_VK_RIGHT: + case KeyEvent.DOM_VK_END: + this.input.maybeConfirmSearchModeFromResult({ + entry: "typed", + }); + // Fall through. + case KeyEvent.DOM_VK_LEFT: + case KeyEvent.DOM_VK_HOME: + this.view.removeAccessibleFocus(); + break; + case KeyEvent.DOM_VK_BACK_SPACE: + if ( + this.input.searchMode && + this.input.selectionStart == 0 && + this.input.selectionEnd == 0 && + !event.shiftKey + ) { + this.input.searchMode = null; + this.input.view.oneOffSearchButtons.selectedButton = null; + this.input.startQuery({ + allowAutofill: false, + event, + }); + } + // Fall through. + case KeyEvent.DOM_VK_DELETE: + if (!this.view.isOpen) { + break; + } + if (event.shiftKey) { + if (!executeAction || this.#dismissSelectedResult(event)) { + event.preventDefault(); + } + } else if (executeAction) { + this.userSelectionBehavior = "none"; + } + break; + } + } + + /** + * Tries to initialize a speculative connection on a result. + * Speculative connections are only supported for a subset of all the results. + * + * Speculative connect to: + * - Search engine heuristic results + * - autofill results + * - http/https results + * + * @param {UrlbarResult} result The result to speculative connect to. + * @param {UrlbarQueryContext} context The queryContext + * @param {string} reason Reason for the speculative connect request. + */ + speculativeConnect(result, context, reason) { + // Never speculative connect in private contexts. + if (!this.input || context.isPrivate || !context.results.length) { + return; + } + let { url } = lazy.UrlbarUtils.getUrlFromResult(result); + if (!url) { + return; + } + + switch (reason) { + case "resultsadded": { + // We should connect to an heuristic result, if it exists. + if ( + (result == context.results[0] && result.heuristic) || + result.autofill + ) { + if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) { + // Speculative connect only if search suggestions are enabled. + if ( + lazy.UrlbarPrefs.get("suggest.searches") && + lazy.UrlbarPrefs.get("browser.search.suggest.enabled") + ) { + let engine = Services.search.getEngineByName( + result.payload.engine + ); + lazy.UrlbarUtils.setupSpeculativeConnection( + engine, + this.browserWindow + ); + } + } else if (result.autofill) { + lazy.UrlbarUtils.setupSpeculativeConnection( + url, + this.browserWindow + ); + } + } + return; + } + case "mousedown": { + // On mousedown, connect only to http/https urls. + if (url.startsWith("http")) { + lazy.UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow); + } + return; + } + default: { + throw new Error("Invalid speculative connection reason"); + } + } + } + + /** + * Stores the selection behavior that the user has used to select a result. + * + * @param {"arrow"|"tab"|"none"} behavior + * The behavior the user used. + */ + set userSelectionBehavior(behavior) { + // Don't change the behavior to arrow if tab has already been recorded, + // as we want to know that the tab was used first. + if (behavior == "arrow" && this._userSelectionBehavior == "tab") { + return; + } + this._userSelectionBehavior = behavior; + } + + /** + * Records details of the selected result in telemetry. We only record the + * selection behavior, type and index. + * + * @param {Event} event + * The event which triggered the result to be selected. + * @param {UrlbarResult} result + * The selected result. + */ + recordSelectedResult(event, result) { + let resultIndex = result ? result.rowIndex : -1; + let selectedResult = -1; + if (resultIndex >= 0) { + // Except for the history popup, the urlbar always has a selection. The + // first result at index 0 is the "heuristic" result that indicates what + // will happen when you press the Enter key. Treat it as no selection. + selectedResult = resultIndex > 0 || !result.heuristic ? resultIndex : -1; + } + lazy.BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod( + event, + "urlbar", + selectedResult, + this._userSelectionBehavior + ); + + if (!result) { + return; + } + + // Do not modify existing telemetry types. To add a new type: + // + // * Set telemetryType appropriately. Since telemetryType is used as the + // probe name, it must be alphanumeric with optional underscores. + // * Add a new keyed scalar probe into the urlbar.picked category for the + // newly added telemetryType. + // * Add a test named browser_UsageTelemetry_urlbar_newType.js to + // browser/modules/test/browser. + // + // The "topsite" type overrides the other ones, because it starts from a + // unique user interaction, that we want to count apart. We do this here + // rather than in telemetryTypeFromResult because other consumers, like + // events telemetry, are reporting this information separately. + let telemetryType = + result.providerName == "UrlbarProviderTopSites" + ? "topsite" + : lazy.UrlbarUtils.telemetryTypeFromResult(result); + Services.telemetry.keyedScalarAdd( + `urlbar.picked.${telemetryType}`, + resultIndex, + 1 + ); + if (this.input.searchMode && !this.input.searchMode.isPreview) { + Services.telemetry.keyedScalarAdd( + `urlbar.picked.searchmode.${this.input.searchMode.entry}`, + resultIndex, + 1 + ); + } + } + + /** + * Triggers a "dismiss" engagement for the selected result if one is selected + * and it's not the heuristic. Providers that can respond to dismissals of + * their results should implement `onEngagement()`, handle the dismissal, and + * call `controller.removeResult()`. + * + * @param {Event} event + * The event that triggered dismissal. + * @returns {boolean} + * Whether providers were notified about the engagement. Providers will not + * be notified if there is no selected result or the selected result is the + * heuristic, since the heuristic result cannot be dismissed. + */ + #dismissSelectedResult(event) { + if (!this._lastQueryContextWrapper) { + console.error("Cannot dismiss selected result, last query not present"); + return false; + } + let { queryContext } = this._lastQueryContextWrapper; + + let { selectedElement } = this.input.view; + if (selectedElement?.classList.contains("urlbarView-button")) { + // For results with buttons, delete them only when the main part of the + // row is selected, not a button. + return false; + } + + let result = this.input.view.selectedResult; + if (!result || result.heuristic) { + return false; + } + + this.engagementEvent.record(event, { + result, + selType: "dismiss", + searchString: queryContext.searchString, + }); + + return true; + } + + /** + * Removes a result from the current query context and notifies listeners. + * Heuristic results cannot be removed. + * + * @param {UrlbarResult} result + * The result to remove. + */ + removeResult(result) { + if (!result || result.heuristic) { + return; + } + + if (!this._lastQueryContextWrapper) { + console.error("Cannot remove result, last query not present"); + return; + } + let { queryContext } = this._lastQueryContextWrapper; + + let index = queryContext.results.indexOf(result); + if (index < 0) { + console.error("Failed to find the selected result in the results"); + return; + } + + queryContext.results.splice(index, 1); + this.notify(NOTIFICATIONS.QUERY_RESULT_REMOVED, index); + } + + /** + * Clear the previous query context cache. + */ + clearLastQueryContextCache() { + this._lastQueryContextWrapper = null; + } + + /** + * Notifies listeners of results. + * + * @param {string} name Name of the notification. + * @param {object} params Parameters to pass with the notification. + */ + notify(name, ...params) { + for (let listener of this._listeners) { + // Can't use "in" because some tests proxify these. + if (typeof listener[name] != "undefined") { + try { + listener[name](...params); + } catch (ex) { + console.error(ex); + } + } + } + } +} + +/** + * Tracks and records telemetry events for the given category, if provided, + * otherwise it's a no-op. + * It is currently designed around the "urlbar" category, even if it can + * potentially be extended to other categories. + * To record an event, invoke start() with a starting event, then either + * invoke record() with a final event, or discard() to drop the recording. + * + * @see Events.yaml + */ +class TelemetryEvent { + constructor(controller, category) { + this._controller = controller; + this._category = category; + this._isPrivate = controller.input.isPrivate; + this.#exposureResultTypes = new Set(); + this.#beginObservingPingPrefs(); + } + + /** + * Start measuring the elapsed time from a user-generated event. + * After this has been invoked, any subsequent calls to start() are ignored, + * until either record() or discard() are invoked. Thus, it is safe to keep + * invoking this on every input event as the user is typing, for example. + * + * @param {event} event A DOM event. + * @param {string} [searchString] Pass a search string related to the event if + * you have one. The event by itself sometimes isn't enough to + * determine the telemetry details we should record. + * @throws This should never throw, or it may break the urlbar. + * @see {@link https://firefox-source-docs.mozilla.org/browser/urlbar/telemetry.html} + */ + start(event, searchString = null) { + if (this._startEventInfo) { + if (this._startEventInfo.interactionType == "topsites") { + // If the most recent event came from opening the results pane with an + // empty string replace the interactionType (that would be "topsites") + // with one for the current event to better measure the user flow. + this._startEventInfo.interactionType = this._getStartInteractionType( + event, + searchString + ); + this._startEventInfo.searchString = searchString; + } else if ( + this._startEventInfo.interactionType == "returned" && + (!searchString || + this._startEventInfo.searchString[0] != searchString[0]) + ) { + // In case of a "returned" interaction ongoing, the user may either + // continue the search, or restart with a new search string. In that case + // we want to change the interaction type to "restarted". + // Detecting all the possible ways of clearing the input would be tricky, + // thus this makes a guess by just checking the first char matches; even if + // the user backspaces a part of the string, we still count that as a + // "returned" interaction. + this._startEventInfo.interactionType = "restarted"; + } + + // start is invoked on a user-generated event, but we only count the first + // one. Once an engagement or abandoment happens, we clear _startEventInfo. + return; + } + + if (!this._category) { + return; + } + if (!event) { + console.error("Must always provide an event"); + return; + } + const validEvents = [ + "click", + "command", + "drop", + "input", + "keydown", + "mousedown", + "tabswitch", + "focus", + ]; + if (!validEvents.includes(event.type)) { + console.error("Can't start recording from event type: ", event.type); + return; + } + + this._startEventInfo = { + timeStamp: event.timeStamp || Cu.now(), + interactionType: this._getStartInteractionType(event, searchString), + searchString, + }; + + let { queryContext } = this._controller._lastQueryContextWrapper || {}; + + this._controller.manager.notifyEngagementChange( + this._isPrivate, + "start", + queryContext, + {}, + this._controller.browserWindow + ); + } + + /** + * Record an engagement telemetry event. + * When the user picks a result from a search through the mouse or keyboard, + * an engagement event is recorded. If instead the user abandons a search, by + * blurring the input field, an abandonment event is recorded. + * + * On return, `details.isSessionOngoing` will be set to true if the engagement + * did not end the search session. Not all engagements end the session. The + * session remains ongoing when certain commands are picked (like dismissal) + * and results that enter search mode are picked. + * + * @param {event} [event] + * A DOM event. + * Note: event can be null, that usually happens for paste&go or drop&go. + * If there's no _startEventInfo this is a no-op. + * @param {object} details An object describing action details. + * @param {string} [details.searchString] The user's search string. Note that + * this string is not sent with telemetry data. It is only used + * locally to discern other data, such as the number of characters and + * words in the string. + * @param {string} [details.selType] type of the selected element, undefined + * for "blur". One of "unknown", "autofill", "visiturl", "bookmark", + * "help", "history", "keyword", "searchengine", "searchsuggestion", + * "switchtab", "remotetab", "extension", "oneoff", "dismiss". + * @param {UrlbarResult} [details.result] The engaged result. This should be + * set to the result related to the picked element. + * @param {DOMElement} [details.element] The picked view element. + */ + record(event, details) { + this.clearPauseImpressionTimer(); + + // This should never throw, or it may break the urlbar. + try { + this._internalRecord(event, details); + } catch (ex) { + console.error("Could not record event: ", ex); + } finally { + // Reset the start event info except for engagements that do not end the + // search session. In that case, the view stays open and further + // engagements are possible and should be recorded when they occur. + // (`details.isSessionOngoing` is not a param; rather, it's set by + // `_internalRecord()`.) + if (!details.isSessionOngoing) { + this._startEventInfo = null; + this._discarded = false; + } + } + } + + /** + * Clear the pause impression timer started by startPauseImpressionTimer(). + */ + clearPauseImpressionTimer() { + lazy.clearTimeout(this._pauseImpressionTimer); + } + + /** + * Start a timer that records the pause impression telemetry for given context. + * The telemetry will be recorded after + * "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs" ms. + * If want to clear this timer, please use clearPauseImpressionTimer(). + * + * @param {UrlbarQueryContext} queryContext + * The query details that will be recorded as pause impression telemetry. + * @param {string} searchSource + * The seach source that will be recorded as pause impression telemetry. + */ + startPauseImpressionTimer(queryContext, searchSource) { + if (this._impressionStartEventInfo === this._startEventInfo) { + // Already took an impression telemetry for this session. + return; + } + + this.clearPauseImpressionTimer(); + this._pauseImpressionTimer = lazy.setTimeout(() => { + let { numChars, numWords, searchWords } = this._parseSearchString( + queryContext.searchString + ); + this._recordSearchEngagementTelemetry( + queryContext, + "impression", + this._startEventInfo, + { + reason: "pause", + numChars, + numWords, + searchWords, + searchSource, + } + ); + + this._impressionStartEventInfo = this._startEventInfo; + }, lazy.UrlbarPrefs.get("searchEngagementTelemetry.pauseImpressionIntervalMs")); + } + + _internalRecord(event, details) { + const startEventInfo = this._startEventInfo; + + if (!this._category || !startEventInfo) { + if (this._discarded && this._category && details?.selType !== "dismiss") { + let { queryContext } = this._controller._lastQueryContextWrapper || {}; + this._controller.manager.notifyEngagementChange( + this._isPrivate, + "discard", + queryContext, + {}, + this._controller.browserWindow + ); + } + return; + } + if ( + !event && + startEventInfo.interactionType != "pasted" && + startEventInfo.interactionType != "dropped" + ) { + // If no event is passed, we must be executing either paste&go or drop&go. + throw new Error("Event must be defined, unless input was pasted/dropped"); + } + if (!details) { + throw new Error("Invalid event details: " + details); + } + + let action; + let skipLegacyTelemetry = false; + if (!event) { + action = + startEventInfo.interactionType == "dropped" ? "drop_go" : "paste_go"; + } else if (event.type == "blur") { + action = "blur"; + } else if ( + details.element?.dataset.command && + // The "help" selType is recognized by legacy telemetry, and `action` + // should be set to either "click" or "enter" depending on whether the + // event is a mouse event, so ignore "help" here. + details.element.dataset.command != "help" + ) { + action = details.element.dataset.command; + skipLegacyTelemetry = true; + } else if (details.selType == "dismiss") { + action = "dismiss"; + skipLegacyTelemetry = true; + } else if (MouseEvent.isInstance(event)) { + action = event.target.id == "urlbar-go-button" ? "go_button" : "click"; + } else { + action = "enter"; + } + + let method = action == "blur" ? "abandonment" : "engagement"; + + if (method == "engagement") { + // Not all engagements end the search session. The session remains ongoing + // when certain commands are picked (like dismissal) and results that + // enter search mode are picked. We should find a generalized way to + // determine this instead of listing all the cases like this. + details.isSessionOngoing = !!( + ["dismiss", "inaccurate_location", "show_less_frequently"].includes( + details.selType + ) || details.result?.payload.providesSearchMode + ); + } + + // numWords is not a perfect measurement, since it will return an incorrect + // value for languages that do not use spaces or URLs containing spaces in + // its query parameters, for example. + let { numChars, numWords, searchWords } = this._parseSearchString( + details.searchString + ); + + details.provider = details.result?.providerName; + details.selIndex = details.result?.rowIndex ?? -1; + + let { queryContext } = this._controller._lastQueryContextWrapper || {}; + + this._recordSearchEngagementTelemetry( + queryContext, + method, + startEventInfo, + { + action, + numChars, + numWords, + searchWords, + provider: details.provider, + searchSource: details.searchSource, + searchMode: details.searchMode, + selectedElement: details.element, + selIndex: details.selIndex, + selType: details.selType, + } + ); + + if (skipLegacyTelemetry) { + this._controller.manager.notifyEngagementChange( + this._isPrivate, + method, + queryContext, + details, + this._controller.browserWindow + ); + return; + } + + if (action == "go_button") { + // Fall back since the conventional telemetry dones't support "go_button" action. + action = "click"; + } + + let endTime = (event && event.timeStamp) || Cu.now(); + let startTime = startEventInfo.timeStamp || endTime; + // Synthesized events in tests may have a bogus timeStamp, causing a + // subtraction between monotonic and non-monotonic timestamps; that's why + // abs is necessary here. It should only happen in tests, anyway. + let elapsed = Math.abs(Math.round(endTime - startTime)); + + // Rather than listening to the pref, just update status when we record an + // event, if the pref changed from the last time. + let recordingEnabled = lazy.UrlbarPrefs.get("eventTelemetry.enabled"); + if (this._eventRecordingEnabled != recordingEnabled) { + this._eventRecordingEnabled = recordingEnabled; + Services.telemetry.setEventRecordingEnabled("urlbar", recordingEnabled); + } + + let extra = { + elapsed: elapsed.toString(), + numChars, + numWords, + }; + + if (method == "engagement") { + extra.selIndex = details.selIndex.toString(); + extra.selType = details.selType; + extra.provider = details.provider || ""; + } + + // We invoke recordEvent regardless, if recording is disabled this won't + // report the events remotely, but will count it in the event_counts scalar. + Services.telemetry.recordEvent( + this._category, + method, + action, + startEventInfo.interactionType, + extra + ); + + Services.telemetry.scalarAdd( + method == "engagement" + ? TELEMETRY_SCALAR_ENGAGEMENT + : TELEMETRY_SCALAR_ABANDONMENT, + 1 + ); + + if ( + method === "engagement" && + queryContext?.view?.visibleResults?.[0]?.autofill + ) { + // Record autofill impressions upon engagement. + const type = lazy.UrlbarUtils.telemetryTypeFromResult( + queryContext.view.visibleResults[0] + ); + Services.telemetry.scalarAdd(`urlbar.impression.${type}`, 1); + } + + this._controller.manager.notifyEngagementChange( + this._isPrivate, + method, + queryContext, + details, + this._controller.browserWindow + ); + } + + _recordSearchEngagementTelemetry( + queryContext, + method, + startEventInfo, + { + action, + numWords, + numChars, + provider, + reason, + searchWords, + searchSource, + searchMode, + selectedElement, + selIndex, + selType, + } + ) { + const browserWindow = this._controller.browserWindow; + let sap = "urlbar"; + if (searchSource === "urlbar-handoff") { + sap = "handoff"; + } else if ( + browserWindow.isBlankPageURL(browserWindow.gBrowser.currentURI.spec) + ) { + sap = "urlbar_newtab"; + } else if (browserWindow.gBrowser.currentURI.schemeIs("moz-extension")) { + sap = "urlbar_addonpage"; + } + + searchMode = searchMode ?? this._controller.input.searchMode; + + // Distinguish user typed search strings from persisted search terms. + const interaction = this.#getInteractionType( + method, + startEventInfo, + searchSource, + searchWords, + searchMode + ); + const search_mode = this.#getSearchMode(searchMode); + const currentResults = queryContext?.view?.visibleResults ?? []; + let numResults = currentResults.length; + let groups = currentResults + .map(r => lazy.UrlbarUtils.searchEngagementTelemetryGroup(r)) + .join(","); + let results = currentResults + .map(r => lazy.UrlbarUtils.searchEngagementTelemetryType(r)) + .join(","); + + let eventInfo; + if (method === "engagement") { + const selected_result = lazy.UrlbarUtils.searchEngagementTelemetryType( + currentResults[selIndex], + selType + ); + const selected_result_subtype = + lazy.UrlbarUtils.searchEngagementTelemetrySubtype( + currentResults[selIndex], + selectedElement + ); + + if (selected_result === "input_field" && !queryContext?.view?.isOpen) { + numResults = 0; + groups = ""; + results = ""; + } + + eventInfo = { + sap, + interaction, + search_mode, + n_chars: numChars, + n_words: numWords, + n_results: numResults, + selected_result, + selected_result_subtype, + provider, + engagement_type: + selType === "help" || selType === "dismiss" ? selType : action, + groups, + results, + }; + } else if (method === "abandonment") { + eventInfo = { + sap, + interaction, + search_mode, + n_chars: numChars, + n_words: numWords, + n_results: numResults, + groups, + results, + }; + } else if (method === "impression") { + eventInfo = { + reason, + sap, + interaction, + search_mode, + n_chars: numChars, + n_words: numWords, + n_results: numResults, + groups, + results, + }; + } else { + console.error(`Unknown telemetry event method: ${method}`); + return; + } + + // First check to see if we can record an exposure event + if ( + (method === "abandonment" || method === "engagement") && + this.#exposureResultTypes.size + ) { + const exposureResults = Array.from(this.#exposureResultTypes).join(","); + this._controller.logger.debug( + `exposure event: ${JSON.stringify({ results: exposureResults })}` + ); + Glean.urlbar.exposure.record({ results: exposureResults }); + + // reset the provider list on the controller + this.#exposureResultTypes.clear(); + } + + this._controller.logger.info( + `${method} event: ${JSON.stringify(eventInfo)}` + ); + + Glean.urlbar[method].record(eventInfo); + } + + /** + * Add result type to engagementEvent instance exposureResultTypes Set. + * + * @param {UrlbarResult} result UrlbarResult to have exposure recorded. + */ + addExposure(result) { + if (result.exposureResultType) { + this.#exposureResultTypes.add(result.exposureResultType); + } + } + + #getInteractionType( + method, + startEventInfo, + searchSource, + searchWords, + searchMode + ) { + if (searchMode?.entry === "topsites_newtab") { + return "topsite_search"; + } + + let interaction = startEventInfo.interactionType; + if ( + (interaction === "returned" || interaction === "restarted") && + this._isRefined(new Set(searchWords), this.#previousSearchWordsSet) + ) { + interaction = "refined"; + } + + if (searchSource === "urlbar-persisted") { + switch (interaction) { + case "returned": { + interaction = "persisted_search_terms"; + break; + } + case "restarted": + case "refined": { + interaction = `persisted_search_terms_${interaction}`; + break; + } + } + } + + if ( + (method === "engagement" && + lazy.UrlbarPrefs.isPersistedSearchTermsEnabled()) || + method === "abandonment" + ) { + this.#previousSearchWordsSet = new Set(searchWords); + } else if (method === "engagement") { + this.#previousSearchWordsSet = null; + } + + return interaction; + } + + #getSearchMode(searchMode) { + if (!searchMode) { + return ""; + } + + if (searchMode.engineName) { + return "search_engine"; + } + + const source = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find( + m => m.source == searchMode.source + )?.telemetryLabel; + return source ?? "unknown"; + } + + _parseSearchString(searchString) { + let numChars = searchString.length.toString(); + let searchWords = searchString + .substring(0, lazy.UrlbarUtils.MAX_TEXT_LENGTH) + .trim() + .split(lazy.UrlbarTokenizer.REGEXP_SPACES) + .filter(t => t); + let numWords = searchWords.length.toString(); + + return { + numChars, + numWords, + searchWords, + }; + } + + /** + * Checks whether re-searched by modifying some of the keywords from the + * previous search. Concretely, returns true if there is intersects between + * both keywords, otherwise returns false. Also, returns false even if both + * are the same. + * + * @param {Set} currentSet The current keywords. + * @param {Set} [previousSet] The previous keywords. + * @returns {boolean} true if current searching are refined. + */ + _isRefined(currentSet, previousSet = null) { + if (!previousSet) { + return false; + } + + const intersect = (setA, setB) => { + let count = 0; + for (const word of setA.values()) { + if (setB.has(word)) { + count += 1; + } + } + return count > 0 && count != setA.size; + }; + + return ( + intersect(currentSet, previousSet) || intersect(previousSet, currentSet) + ); + } + + _getStartInteractionType(event, searchString) { + if (event.interactionType) { + return event.interactionType; + } else if (event.type == "input") { + return lazy.UrlbarUtils.isPasteEvent(event) ? "pasted" : "typed"; + } else if (event.type == "drop") { + return "dropped"; + } else if (searchString) { + return "typed"; + } + return "topsites"; + } + + /** + * Resets the currently tracked user-generated event that was registered via + * start(), so it won't be recorded. If there's no tracked event, this is a + * no-op. + */ + discard() { + this.clearPauseImpressionTimer(); + if (this._startEventInfo) { + this._startEventInfo = null; + this._discarded = true; + } + } + + /** + * Extracts a telemetry type from a result and the element being interacted + * with for event telemetry. + * + * @param {object} result The element to analyze. + * @param {Element} element The element to analyze. + * @returns {string} a string type for the telemetry event. + */ + typeFromElement(result, element) { + if (!element) { + return "none"; + } + if ( + element.classList.contains("urlbarView-button-help") || + element.dataset.command == "help" + ) { + return result?.type == lazy.UrlbarUtils.RESULT_TYPE.TIP + ? "tiphelp" + : "help"; + } + if ( + element.classList.contains("urlbarView-button-block") || + element.dataset.command == "dismiss" + ) { + return "block"; + } + // Now handle the result. + return lazy.UrlbarUtils.telemetryTypeFromResult(result); + } + + /** + * Reset the internal state. This function is used for only when testing. + */ + reset() { + this.#previousSearchWordsSet = null; + } + + #PING_PREFS = { + maxRichResults: Glean.urlbar.prefMaxResults, + "suggest.topsites": Glean.urlbar.prefSuggestTopsites, + }; + + #beginObservingPingPrefs() { + for (const p of Object.keys(this.#PING_PREFS)) { + this.onPrefChanged(p); + } + lazy.UrlbarPrefs.addObserver(this); + } + + onPrefChanged(pref) { + const metric = this.#PING_PREFS[pref]; + if (metric) { + metric.set(lazy.UrlbarPrefs.get(pref)); + } + } + + #previousSearchWordsSet = null; + + #exposureResultTypes; +} diff --git a/browser/components/urlbar/UrlbarEventBufferer.sys.mjs b/browser/components/urlbar/UrlbarEventBufferer.sys.mjs new file mode 100644 index 0000000000..5caa918be2 --- /dev/null +++ b/browser/components/urlbar/UrlbarEventBufferer.sys.mjs @@ -0,0 +1,366 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: "EventBufferer" }) +); + +// Maximum time events can be deferred for. In automation providers can be quite +// slow, thus we need a longer timeout to avoid intermittent failures. +const DEFERRING_TIMEOUT_MS = Cu.isInAutomation ? 1000 : 300; + +// Array of keyCodes to defer. +const DEFERRED_KEY_CODES = new Set([ + KeyboardEvent.DOM_VK_RETURN, + KeyboardEvent.DOM_VK_DOWN, + KeyboardEvent.DOM_VK_TAB, +]); + +// Status of the current or last query. +const QUERY_STATUS = { + UKNOWN: 0, + RUNNING: 1, + RUNNING_GOT_RESULTS: 2, + COMPLETE: 3, +}; + +/** + * The UrlbarEventBufferer can queue up events and replay them later, to make + * the urlbar results more predictable. + * + * Search results arrive asynchronously, which means that keydown events may + * arrive before results do, and therefore not have the effect the user intends. + * That's especially likely to happen with the down arrow and enter keys, due to + * the one-off search buttons: if the user very quickly pastes something in the + * input, presses the down arrow key, and then hits enter, they are probably + * expecting to visit the first result. But if there are no results, then + * pressing down and enter will trigger the first one-off button. + * To prevent that undesirable behavior, certain keys are buffered and deferred + * until more results arrive, at which time they're replayed. + */ +export class UrlbarEventBufferer { + /** + * Initialises the class. + * + * @param {UrlbarInput} input The urlbar input object. + */ + constructor(input) { + this.input = input; + this.input.inputField.addEventListener("blur", this); + + // A queue of {event, callback} objects representing deferred events. + // The callback is invoked when it's the right time to handle the event, + // but it may also never be invoked, if the context changed and the event + // became obsolete. + this._eventsQueue = []; + // If this timer fires, we will unconditionally replay all the deferred + // events so that, after a certain point, we don't keep blocking the user's + // actions, when nothing else has caused the events to be replayed. + // At that point we won't check whether it's safe to replay the events, + // because otherwise it may look like we ignored the user's actions. + this._deferringTimeout = null; + + // Tracks the current or last query status. + this._lastQuery = { + // The time at which the current or last search was started. This is used + // to check how much time passed while deferring the user's actions. Must + // be set using the monotonic Cu.now() helper. + startDate: Cu.now(), + // Status of the query; one of QUERY_STATUS.* + status: QUERY_STATUS.UKNOWN, + // The query context. + context: null, + }; + + // Start listening for queries. + this.input.controller.addQueryListener(this); + } + + // UrlbarController listener methods. + onQueryStarted(queryContext) { + this._lastQuery = { + startDate: Cu.now(), + status: QUERY_STATUS.RUNNING, + context: queryContext, + }; + if (this._deferringTimeout) { + lazy.clearTimeout(this._deferringTimeout); + this._deferringTimeout = null; + } + } + + onQueryCancelled(queryContext) { + this._lastQuery.status = QUERY_STATUS.COMPLETE; + } + + onQueryFinished(queryContext) { + this._lastQuery.status = QUERY_STATUS.COMPLETE; + } + + onQueryResults(queryContext) { + this._lastQuery.status = QUERY_STATUS.RUNNING_GOT_RESULTS; + // Ensure this runs after other results handling code. + Services.tm.dispatchToMainThread(() => { + this.replayDeferredEvents(true); + }); + } + + /** + * Handles DOM events. + * + * @param {Event} event DOM event from the input. + */ + handleEvent(event) { + if (event.type == "blur") { + lazy.logger.debug("Clearing queue on blur"); + // The input field was blurred, pending events don't matter anymore. + // Clear the timeout and the queue. + this._eventsQueue.length = 0; + if (this._deferringTimeout) { + lazy.clearTimeout(this._deferringTimeout); + this._deferringTimeout = null; + } + } + } + + /** + * Receives DOM events, eventually queues them up, and calls back when it's + * the right time to handle the event. + * + * @param {Event} event DOM event from the input. + * @param {Function} callback to be invoked when it's the right time to handle + * the event. + */ + maybeDeferEvent(event, callback) { + if (!callback) { + throw new Error("Must provide a callback"); + } + if (this.shouldDeferEvent(event)) { + this.deferEvent(event, callback); + return; + } + // If it has not been deferred, handle the callback immediately. + callback(); + } + + /** + * Adds a deferrable event to the deferred event queue. + * + * @param {Event} event The event to defer. + * @param {Function} callback to be invoked when it's the right time to handle + * the event. + */ + deferEvent(event, callback) { + // TODO Bug 1536822: once one-off buttons are implemented, figure out if the + // following is true for the quantum bar as well: somehow event.defaultPrevented + // ends up true for deferred events. Autocomplete ignores defaultPrevented + // events, which means it would ignore replayed deferred events if we didn't + // tell it to bypass defaultPrevented through urlbarDeferred. + // Check we don't try to defer events more than once. + if (event.urlbarDeferred) { + throw new Error(`Event ${event.type}:${event.keyCode} already deferred!`); + } + lazy.logger.debug(`Deferring ${event.type}:${event.keyCode} event`); + // Mark the event as deferred. + event.urlbarDeferred = true; + // Also store the current search string, as an added safety check. If the + // string will differ later, the event is stale and should be dropped. + event.searchString = this._lastQuery.context.searchString; + this._eventsQueue.push({ event, callback }); + + if (!this._deferringTimeout) { + let elapsed = Cu.now() - this._lastQuery.startDate; + let remaining = DEFERRING_TIMEOUT_MS - elapsed; + this._deferringTimeout = lazy.setTimeout(() => { + this.replayDeferredEvents(false); + this._deferringTimeout = null; + }, Math.max(0, remaining)); + } + } + + /** + * Replays deferred key events. + * + * @param {boolean} onlyIfSafe replays only if it's a safe time to do so. + * Setting this to false will replay all the queue events, without any + * checks, that is something we want to do only if the deferring + * timeout elapsed, and we don't want to appear ignoring user's input. + */ + replayDeferredEvents(onlyIfSafe) { + if (typeof onlyIfSafe != "boolean") { + throw new Error("Must provide a boolean argument"); + } + if (!this._eventsQueue.length) { + return; + } + + let { event, callback } = this._eventsQueue[0]; + if (onlyIfSafe && !this.isSafeToPlayDeferredEvent(event)) { + return; + } + + // Remove the event from the queue and play it. + this._eventsQueue.shift(); + // Safety check: handle only if the search string didn't change meanwhile. + if (event.searchString == this._lastQuery.context.searchString) { + callback(); + } + Services.tm.dispatchToMainThread(() => { + this.replayDeferredEvents(onlyIfSafe); + }); + } + + /** + * Checks whether a given event should be deferred + * + * @param {Event} event The event that should maybe be deferred. + * @returns {boolean} Whether the event should be deferred. + */ + shouldDeferEvent(event) { + // If any event has been deferred for this search, then defer all subsequent + // events so that the user does not experience them out of order. + // All events will be replayed when _deferringTimeout fires. + if (this._eventsQueue.length) { + return true; + } + + // At this point, no events have been deferred for this search; we must + // figure out if this event should be deferred. + let isMacNavigation = + AppConstants.platform == "macosx" && + event.ctrlKey && + this.input.view.isOpen && + (event.key === "n" || event.key === "p"); + if (!DEFERRED_KEY_CODES.has(event.keyCode) && !isMacNavigation) { + return false; + } + + if (DEFERRED_KEY_CODES.has(event.keyCode)) { + // Defer while the user is composing. + if (this.input.editor.composing) { + return true; + } + if (this.input.controller.keyEventMovesCaret(event)) { + return false; + } + } + + // This is an event that we'd defer, but if enough time has passed since the + // start of the search, we don't want to block the user's workflow anymore. + if (this._lastQuery.startDate + DEFERRING_TIMEOUT_MS <= Cu.now()) { + return false; + } + + if ( + event.keyCode == KeyEvent.DOM_VK_TAB && + !this.input.view.isOpen && + !this.waitingDeferUserSelectionProviders + ) { + // The view is closed and the user pressed the Tab key. The focus should + // move out of the urlbar immediately. + return false; + } + + return !this.isSafeToPlayDeferredEvent(event); + } + + /** + * Checks if the bufferer is deferring events. + * + * @returns {boolean} Whether the bufferer is deferring events. + */ + get isDeferringEvents() { + return !!this._eventsQueue.length; + } + + /** + * Checks if any of the current query provider asked to defer user selection + * events. + * + * @returns {boolean} Whether a provider asked to defer events. + */ + get waitingDeferUserSelectionProviders() { + return !!this._lastQuery.context?.deferUserSelectionProviders.size; + } + + /** + * Returns true if the given deferred event can be played now without possibly + * surprising the user. This depends on the state of the view, the results, + * and the type of event. + * Use this method only after determining that the event should be deferred, + * or after it has been deferred and you want to know if it can be played now. + * + * @param {Event} event The event. + * @returns {boolean} Whether the event can be played. + */ + isSafeToPlayDeferredEvent(event) { + if ( + this._lastQuery.status != QUERY_STATUS.RUNNING && + this._lastQuery.status != QUERY_STATUS.RUNNING_GOT_RESULTS + ) { + // The view can't get any more results, so there's no need to further + // defer events. + return true; + } + let waitingFirstResult = this._lastQuery.status == QUERY_STATUS.RUNNING; + if (event.keyCode == KeyEvent.DOM_VK_RETURN) { + // Check if we're waiting for providers that requested deferring. + if (this.waitingDeferUserSelectionProviders) { + return false; + } + // Play a deferred Enter if the heuristic result is not selected, or we + // are not waiting for the first results yet. + let selectedResult = this.input.view.selectedResult; + return ( + (selectedResult && !selectedResult.heuristic) || !waitingFirstResult + ); + } + + if ( + waitingFirstResult || + !this.input.view.isOpen || + this.waitingDeferUserSelectionProviders + ) { + // We're still waiting on some results, or the popup hasn't opened yet. + return false; + } + + let isMacDownNavigation = + AppConstants.platform == "macosx" && + event.ctrlKey && + this.input.view.isOpen && + event.key === "n"; + if (event.keyCode == KeyEvent.DOM_VK_DOWN || isMacDownNavigation) { + // Don't play the event if the last result is selected so that the user + // doesn't accidentally arrow down into the one-off buttons when they + // didn't mean to. Note TAB is unaffected because it only navigates + // results, not one-offs. + return !this.lastResultIsSelected; + } + + return true; + } + + get lastResultIsSelected() { + // TODO Bug 1536818: Once one-off buttons are fully implemented, it would be + // nice to have a better way to check if the next down will focus one-off buttons. + let results = this._lastQuery.context.results; + return ( + results.length && + results[results.length - 1] == this.input.view.selectedResult + ); + } +} diff --git a/browser/components/urlbar/UrlbarInput.sys.mjs b/browser/components/urlbar/UrlbarInput.sys.mjs new file mode 100644 index 0000000000..51b822759b --- /dev/null +++ b/browser/components/urlbar/UrlbarInput.sys.mjs @@ -0,0 +1,4268 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + ExtensionSearchHandler: + "resource://gre/modules/ExtensionSearchHandler.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs", + SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarValueFormatter: "resource:///modules/UrlbarValueFormatter.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "QueryStringStripper", + "@mozilla.org/url-query-string-stripper;1", + "nsIURLQueryStringStripper" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "QUERY_STRIPPING_STRIP_ON_SHARE", + "privacy.query_stripping.strip_on_share.enabled", + false +); + +const DEFAULT_FORM_HISTORY_NAME = "searchbar-history"; +const SEARCH_BUTTON_ID = "urlbar-search-button"; + +// The scalar category of TopSites click for Contextual Services +const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.click"; + +let getBoundsWithoutFlushing = element => + element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element); +let px = number => number.toFixed(2) + "px"; + +/** + * Implements the text input part of the address bar UI. + */ +export class UrlbarInput { + /** + * @param {object} options + * The initial options for UrlbarInput. + * @param {object} options.textbox + * The container element. + */ + constructor(options = {}) { + this.textbox = options.textbox; + + this.window = this.textbox.ownerGlobal; + this.isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(this.window); + this.document = this.window.document; + + // Create the panel to contain results. + this.textbox.appendChild( + this.window.MozXULElement.parseXULToFragment(` + <vbox class="urlbarView" + role="group" + tooltip="aHTMLTooltip"> + <html:div class="urlbarView-body-outer"> + <html:div class="urlbarView-body-inner"> + <html:div id="urlbar-results" + class="urlbarView-results" + role="listbox"/> + </html:div> + </html:div> + <menupopup class="urlbarView-result-menu" + consumeoutsideclicks="false"/> + <hbox class="search-one-offs" + includecurrentengine="true" + disabletab="true"/> + </vbox> + `) + ); + this.panel = this.textbox.querySelector(".urlbarView"); + + this.searchButton = lazy.UrlbarPrefs.get("experimental.searchButton"); + if (this.searchButton) { + this.textbox.classList.add("searchButton"); + } + + this.controller = new lazy.UrlbarController({ + input: this, + eventTelemetryCategory: options.eventTelemetryCategory, + }); + this.view = new lazy.UrlbarView(this); + this.valueIsTyped = false; + this.formHistoryName = DEFAULT_FORM_HISTORY_NAME; + this.lastQueryContextPromise = Promise.resolve(); + this._actionOverrideKeyCount = 0; + this._autofillPlaceholder = null; + this._lastSearchString = ""; + this._lastValidURLStr = ""; + this._valueOnLastSearch = ""; + this._resultForCurrentValue = null; + this._suppressStartQuery = false; + this._suppressPrimaryAdjustment = false; + this._untrimmedValue = ""; + + // Search modes are per browser and are stored in this map. For a + // browser, search mode can be in preview mode, confirmed, or both. + // Typically, search mode is entered in preview mode with a particular + // source and is confirmed with the same source once a query starts. It's + // also possible for a confirmed search mode to be replaced with a preview + // mode with a different source, and in those cases, we need to re-confirm + // search mode when preview mode is exited. In addition, only confirmed + // search modes should be restored across sessions. We therefore need to + // keep track of both the current confirmed and preview modes, per browser. + // + // For each browser with a search mode, this maps the browser to an object + // like this: { preview, confirmed }. Both `preview` and `confirmed` are + // search mode objects; see the setSearchMode documentation. Either one may + // be undefined if that particular mode is not active for the browser. + this._searchModesByBrowser = new WeakMap(); + + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + this._addObservers(); + + // This exists only for tests. + this._enableAutofillPlaceholder = true; + + // Forward certain methods and properties. + const CONTAINER_METHODS = [ + "getAttribute", + "hasAttribute", + "querySelector", + "setAttribute", + "removeAttribute", + "toggleAttribute", + ]; + const INPUT_METHODS = ["addEventListener", "blur", "removeEventListener"]; + const READ_WRITE_PROPERTIES = [ + "placeholder", + "readOnly", + "selectionStart", + "selectionEnd", + ]; + + for (let method of CONTAINER_METHODS) { + this[method] = (...args) => { + return this.textbox[method](...args); + }; + } + + for (let method of INPUT_METHODS) { + this[method] = (...args) => { + return this.inputField[method](...args); + }; + } + + for (let property of READ_WRITE_PROPERTIES) { + Object.defineProperty(this, property, { + enumerable: true, + get() { + return this.inputField[property]; + }, + set(val) { + this.inputField[property] = val; + }, + }); + } + + this.inputField = this.querySelector("#urlbar-input"); + this._inputContainer = this.querySelector("#urlbar-input-container"); + this._identityBox = this.querySelector("#identity-box"); + this._searchModeIndicator = this.querySelector( + "#urlbar-search-mode-indicator" + ); + this._searchModeIndicatorTitle = this._searchModeIndicator.querySelector( + "#urlbar-search-mode-indicator-title" + ); + this._searchModeIndicatorClose = this._searchModeIndicator.querySelector( + "#urlbar-search-mode-indicator-close" + ); + this._searchModeLabel = this.querySelector("#urlbar-label-search-mode"); + this._toolbar = this.textbox.closest("toolbar"); + + XPCOMUtils.defineLazyGetter(this, "valueFormatter", () => { + return new lazy.UrlbarValueFormatter(this); + }); + + XPCOMUtils.defineLazyGetter(this, "addSearchEngineHelper", () => { + return new AddSearchEngineHelper(this); + }); + + // If the toolbar is not visible in this window or the urlbar is readonly, + // we'll stop here, so that most properties of the input object are valid, + // but we won't handle events. + if (!this.window.toolbar.visible || this.readOnly) { + return; + } + + // The event bufferer can be used to defer events that may affect users + // muscle memory; for example quickly pressing DOWN+ENTER should end up + // on a predictable result, regardless of the search status. The event + // bufferer will invoke the handling code at the right time. + this.eventBufferer = new lazy.UrlbarEventBufferer(this); + + this._inputFieldEvents = [ + "compositionstart", + "compositionend", + "contextmenu", + "dragover", + "dragstart", + "drop", + "focus", + "blur", + "input", + "beforeinput", + "keydown", + "keyup", + "mouseover", + "overflow", + "underflow", + "paste", + "scrollend", + "select", + ]; + for (let name of this._inputFieldEvents) { + this.addEventListener(name, this); + } + + this.window.addEventListener("mousedown", this); + if (AppConstants.platform == "win") { + this.window.addEventListener("draggableregionleftmousedown", this); + } + this.textbox.addEventListener("mousedown", this); + + // This listener handles clicks from our children too, included the search mode + // indicator close button. + this._inputContainer.addEventListener("click", this); + + // This is used to detect commands launched from the panel, to avoid + // recording abandonment events when the command causes a blur event. + this.view.panel.addEventListener("command", this, true); + + this.window.gBrowser.tabContainer.addEventListener("TabSelect", this); + + this.window.addEventListener("customizationstarting", this); + this.window.addEventListener("aftercustomization", this); + + this.updateLayoutBreakout(); + + this._initCopyCutController(); + this._initPasteAndGo(); + this._initStripOnShare(); + + // Tracks IME composition. + this._compositionState = lazy.UrlbarUtils.COMPOSITION.NONE; + this._compositionClosedPopup = false; + + this.editor.newlineHandling = + Ci.nsIEditor.eNewlinesStripSurroundingWhitespace; + } + + /** + * Applies styling to the text in the urlbar input, depending on the text. + */ + formatValue() { + // The editor may not exist if the toolbar is not visible. + if (this.editor) { + this.valueFormatter.update(); + } + } + + focus() { + let beforeFocus = new CustomEvent("beforefocus", { + bubbles: true, + cancelable: true, + }); + this.inputField.dispatchEvent(beforeFocus); + if (beforeFocus.defaultPrevented) { + return; + } + + this.inputField.focus(); + } + + select() { + let beforeSelect = new CustomEvent("beforeselect", { + bubbles: true, + cancelable: true, + }); + this.inputField.dispatchEvent(beforeSelect); + if (beforeSelect.defaultPrevented) { + return; + } + + // See _on_select(). HTMLInputElement.select() dispatches a "select" + // event but does not set the primary selection. + this._suppressPrimaryAdjustment = true; + this.inputField.select(); + this._suppressPrimaryAdjustment = false; + } + + setSelectionRange(selectionStart, selectionEnd) { + this.focus(); + + let beforeSelect = new CustomEvent("beforeselect", { + bubbles: true, + cancelable: true, + }); + this.inputField.dispatchEvent(beforeSelect); + if (beforeSelect.defaultPrevented) { + return; + } + + // See _on_select(). HTMLInputElement.select() dispatches a "select" + // event but does not set the primary selection. + this._suppressPrimaryAdjustment = true; + this.inputField.setSelectionRange(selectionStart, selectionEnd); + this._suppressPrimaryAdjustment = false; + } + + /** + * Sets the URI to display in the location bar. + * + * @param {nsIURI} [uri] + * If this is unspecified, the current URI will be used. + * @param {boolean} [dueToTabSwitch] + * True if this is being called due to switching tabs and false + * otherwise. + * @param {boolean} [dueToSessionRestore] + * True if this is being called due to session restore and false + * otherwise. + * @param {boolean} [dontShowSearchTerms] + * True if userTypedValue should not be overidden by search terms + * and false otherwise. + * @param {boolean} [isSameDocument] + * True if the caller of setURI loaded a new document and false + * otherwise (e.g. the location change was from an anchor scroll + * or a pushState event). + */ + setURI( + uri = null, + dueToTabSwitch = false, + dueToSessionRestore = false, + dontShowSearchTerms = false, + isSameDocument = false + ) { + if (!this.window.gBrowser.userTypedValue) { + this.window.gBrowser.selectedBrowser.searchTerms = ""; + if ( + !dontShowSearchTerms && + lazy.UrlbarPrefs.isPersistedSearchTermsEnabled() + ) { + this.window.gBrowser.selectedBrowser.searchTerms = + lazy.UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + this.window.gBrowser.selectedBrowser.originalURI ?? uri + ); + } + } + + let value = this.window.gBrowser.userTypedValue; + let valid = false; + + // If `value` is null or if it's an empty string and we're switching tabs + // or the userTypedValue equals the search terms, set value to either + // search terms or the browser's current URI. When a user empties the input, + // switches tabs, and switches back, we want the URI to become visible again + // so the user knows what URI they're viewing. + // An exception to this is made in case of an auth request from a different + // base domain. To avoid auth prompt spoofing we already display the url of + // the cross domain resource, although the page is not loaded yet. + // This url will be set/unset by PromptParent. See bug 791594 for reference. + if ( + value === null || + (!value && dueToTabSwitch) || + (value && value === this.window.gBrowser.selectedBrowser.searchTerms) + ) { + if (this.window.gBrowser.selectedBrowser.searchTerms) { + value = this.window.gBrowser.selectedBrowser.searchTerms; + valid = !dueToSessionRestore; + if (!isSameDocument) { + Services.telemetry.scalarAdd( + "urlbar.persistedsearchterms.view_count", + 1 + ); + } + } else { + uri = + this.window.gBrowser.selectedBrowser.currentAuthPromptURI || + uri || + (this.window.gBrowser.selectedBrowser.browsingContext.sessionHistory + ?.count === 0 && + this.window.gBrowser.selectedBrowser.browsingContext + .nonWebControlledBlankURI) || + this.window.gBrowser.currentURI; + // Strip off usernames and passwords for the location bar + try { + uri = Services.io.createExposableURI(uri); + } catch (e) {} + + // Replace initial page URIs with an empty string + // only if there's no opener (bug 370555). + if ( + this.window.isInitialPage(uri) && + lazy.BrowserUIUtils.checkEmptyPageOrigin( + this.window.gBrowser.selectedBrowser, + uri + ) + ) { + value = ""; + } else { + // We should deal with losslessDecodeURI throwing for exotic URIs + try { + value = losslessDecodeURI(uri); + } catch (ex) { + value = "about:blank"; + } + } + // If we update the URI while restoring a session, set the proxyState to + // invalid, because we don't have a valid security state to show via site + // identity yet. See Bug 1746383. + valid = + !dueToSessionRestore && + (!this.window.isBlankPageURL(uri.spec) || + uri.schemeIs("moz-extension")); + } + } else if ( + this.window.isInitialPage(value) && + lazy.BrowserUIUtils.checkEmptyPageOrigin( + this.window.gBrowser.selectedBrowser + ) + ) { + value = ""; + valid = true; + } + + const previousUntrimmedValue = this.untrimmedValue; + const previousSelectionStart = this.selectionStart; + const previousSelectionEnd = this.selectionEnd; + + this.value = value; + this.valueIsTyped = !valid; + this.removeAttribute("usertyping"); + + if (this.focused && value != previousUntrimmedValue) { + if ( + previousSelectionStart != previousSelectionEnd && + value.substring(previousSelectionStart, previousSelectionEnd) === + previousUntrimmedValue.substring( + previousSelectionStart, + previousSelectionEnd + ) + ) { + // If the same text is in the same place as the previously selected text, + // the selection is kept. + this.inputField.setSelectionRange( + previousSelectionStart, + previousSelectionEnd + ); + } else if ( + previousSelectionEnd && + (previousUntrimmedValue.length === previousSelectionEnd || + value.length <= previousSelectionEnd) + ) { + // If the previous end caret is not 0 and the caret is at the end of the + // input or its position is beyond the end of the new value, keep the + // position at the end. + this.inputField.setSelectionRange(value.length, value.length); + } else { + // Otherwise clear selection and set the caret position to the previous + // caret end position. + this.inputField.setSelectionRange( + previousSelectionEnd, + previousSelectionEnd + ); + } + } + + // The proxystate must be set before setting search mode below because + // search mode depends on it. + this.setPageProxyState(valid ? "valid" : "invalid", dueToTabSwitch); + + // If we're switching tabs, restore the tab's search mode. Otherwise, if + // the URI is valid, exit search mode. This must happen after setting + // proxystate above because search mode depends on it. + if (dueToTabSwitch && !valid) { + this.restoreSearchModeState(); + } else if (valid) { + this.searchMode = null; + } + + // Dispatch URIUpdate event to synchronize the tab status when switching. + let event = new CustomEvent("SetURI", { bubbles: true }); + this.inputField.dispatchEvent(event); + } + + /** + * Converts an internal URI (e.g. a URI with a username or password) into one + * which we can expose to the user. + * + * @param {nsIURI} uri + * The URI to be converted + * @returns {nsIURI} + * The converted, exposable URI + */ + makeURIReadable(uri) { + // Avoid copying 'about:reader?url=', and always provide the original URI: + // Reader mode ensures we call createExposableURI itself. + let readerStrippedURI = lazy.ReaderMode.getOriginalUrlObjectForDisplay( + uri.displaySpec + ); + if (readerStrippedURI) { + return readerStrippedURI; + } + + try { + return Services.io.createExposableURI(uri); + } catch (ex) {} + + return uri; + } + + /** + * Passes DOM events to the _on_<event type> methods. + * + * @param {Event} event The event to handle. + */ + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarInput event: " + event.type); + } + } + + /** + * Handles an event which might open text or a URL. If the event requires + * doing so, handleCommand forwards it to handleNavigation. + * + * @param {Event} [event] The event triggering the open. + */ + handleCommand(event = null) { + let isMouseEvent = this.window.MouseEvent.isInstance(event); + if (isMouseEvent && event.button == 2) { + // Do nothing for right clicks. + return; + } + + // Determine whether to use the selected one-off search button. In + // one-off search buttons parlance, "selected" means that the button + // has been navigated to via the keyboard. So we want to use it if + // the triggering event is not a mouse click -- i.e., it's a Return + // key -- or if the one-off was mouse-clicked. + if (this.view.isOpen) { + let selectedOneOff = this.view.oneOffSearchButtons.selectedButton; + if (selectedOneOff && (!isMouseEvent || event.target == selectedOneOff)) { + this.view.oneOffSearchButtons.handleSearchCommand(event, { + engineName: selectedOneOff.engine?.name, + source: selectedOneOff.source, + entry: "oneoff", + }); + return; + } + } + + this.handleNavigation({ event }); + } + + /** + * @typedef {object} HandleNavigationOneOffParams + * + * @property {string} openWhere + * Where we expect the result to be opened. + * @property {object} openParams + * The parameters related to where the result will be opened. + * @property {Node} engine + * The selected one-off's engine. + */ + + /** + * Handles an event which would cause a URL or text to be opened. + * + * @param {object} [options] + * Options for the navigation. + * @param {Event} [options.event] + * The event triggering the open. + * @param {HandleNavigationOneOffParams} [options.oneOffParams] + * Optional. Pass if this navigation was triggered by a one-off. Practically + * speaking, UrlbarSearchOneOffs passes this when the user holds certain key + * modifiers while picking a one-off. In those cases, we do an immediate + * search using the one-off's engine instead of entering search mode. + * @param {object} [options.triggeringPrincipal] + * The principal that the action was triggered from. + */ + handleNavigation({ event, oneOffParams, triggeringPrincipal }) { + let element = this.view.selectedElement; + let result = this.view.getResultFromElement(element); + let openParams = oneOffParams?.openParams || {}; + + // If the value was submitted during composition, the result may not have + // been updated yet, because the input event happens after composition end. + // We can't trust element nor _resultForCurrentValue targets in that case, + // so we always generate a new heuristic to load. + let isComposing = this.editor.composing; + + // Use the selected element if we have one; this is usually the case + // when the view is open. + let selectedPrivateResult = + result && + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.inPrivateWindow; + let selectedPrivateEngineResult = + selectedPrivateResult && result.payload.isPrivateEngine; + if ( + !isComposing && + element && + (!oneOffParams?.engine || selectedPrivateEngineResult) + ) { + this.pickElement(element, event); + return; + } + + // Use the hidden heuristic if it exists and there's no selection. + if ( + lazy.UrlbarPrefs.get("experimental.hideHeuristic") && + !element && + !isComposing && + !oneOffParams?.engine && + this._resultForCurrentValue?.heuristic + ) { + this.pickResult(this._resultForCurrentValue, event); + return; + } + + // We don't select a heuristic result when we're autofilling a token alias, + // but we want pressing Enter to behave like the first result was selected. + if (!result && this.value.startsWith("@")) { + let tokenAliasResult = this.view.getResultAtIndex(0); + if (tokenAliasResult?.autofill && tokenAliasResult?.payload.keyword) { + this.pickResult(tokenAliasResult, event); + return; + } + } + + let url; + let selType = this.controller.engagementEvent.typeFromElement( + result, + element + ); + let typedValue = this.value; + if (oneOffParams?.engine) { + selType = "oneoff"; + typedValue = this._lastSearchString; + // If there's a selected one-off button then load a search using + // the button's engine. + result = this._resultForCurrentValue; + + let searchString = + (result && (result.payload.suggestion || result.payload.query)) || + this._lastSearchString; + [url, openParams.postData] = lazy.UrlbarUtils.getSearchQueryUrl( + oneOffParams.engine, + searchString + ); + this._recordSearch(oneOffParams.engine, event, { url }); + + lazy.UrlbarUtils.addToFormHistory( + this, + searchString, + oneOffParams.engine.name + ).catch(console.error); + } else { + // Use the current value if we don't have a UrlbarResult e.g. because the + // view is closed. + url = this.untrimmedValue; + openParams.postData = null; + } + + if (!url) { + return; + } + + // When the user hits enter in a local search mode and there's no selected + // result or one-off, don't do anything. + if ( + this.searchMode && + !this.searchMode.engineName && + !result && + !oneOffParams + ) { + return; + } + + let selectedResult = result || this.view.selectedResult; + this.controller.recordSelectedResult(event, selectedResult); + + let where = oneOffParams?.openWhere || this._whereToOpen(event); + if (selectedPrivateResult) { + where = "window"; + openParams.private = true; + } + openParams.allowInheritPrincipal = false; + url = this._maybeCanonizeURL(event, url) || url.trim(); + + this.controller.engagementEvent.record(event, { + element, + selType, + searchString: typedValue, + result: selectedResult, + }); + + let isValidUrl = false; + try { + new URL(url); + isValidUrl = true; + } catch (ex) {} + if (isValidUrl) { + this._loadURL(url, event, where, openParams); + return; + } + + // This is not a URL and there's no selected element, because likely the + // view is closed, or paste&go was used. + // We must act consistently here, having or not an open view should not + // make a difference if the search string is the same. + + // If we have a result for the current value, we can just use it. + if (!isComposing && this._resultForCurrentValue) { + this.pickResult(this._resultForCurrentValue, event); + return; + } + + // Otherwise, we must fetch the heuristic result for the current value. + // TODO (Bug 1604927): If the urlbar results are restricted to a specific + // engine, here we must search with that specific engine; indeed the + // docshell wouldn't know about our engine restriction. + // Also remember to invoke this._recordSearch, after replacing url with + // the appropriate engine submission url. + let browser = this.window.gBrowser.selectedBrowser; + let lastLocationChange = browser.lastLocationChange; + lazy.UrlbarUtils.getHeuristicResultFor(url, this.window) + .then(newResult => { + // Because this happens asynchronously, we must verify that the browser + // location did not change in the meanwhile. + if ( + where != "current" || + browser.lastLocationChange == lastLocationChange + ) { + this.pickResult(newResult, event, null, browser); + } + }) + .catch(ex => { + if (url) { + // Something went wrong, we should always have a heuristic result, + // otherwise it means we're not able to search at all, maybe because + // some parts of the profile are corrupt. + // The urlbar should still allow to search or visit the typed string, + // so that the user can look for help to resolve the problem. + let flags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (this.isPrivate) { + flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + let { preferredURI: uri, postData } = + Services.uriFixup.getFixupURIInfo(url, flags); + if ( + where != "current" || + browser.lastLocationChange == lastLocationChange + ) { + openParams.postData = postData; + this._loadURL(uri.spec, event, where, openParams, null, browser); + } + } + }); + // Don't add further handling here, the catch above is our last resort. + } + + handleRevert(dontShowSearchTerms = false) { + this.window.gBrowser.userTypedValue = null; + // Nullify search mode before setURI so it won't try to restore it. + this.searchMode = null; + this.setURI(null, true, false, dontShowSearchTerms); + if (this.value && this.focused) { + this.select(); + } + } + + maybeHandleRevertFromPopup(anchorElement) { + if ( + anchorElement?.closest("#urlbar") && + this.window.gBrowser.selectedBrowser.searchTerms + ) { + // The Persist Search Tip can be open while a PopupNotification is queued + // to appear, so ensure that the tip is closed. + this.view.close(); + + this.handleRevert(true); + Services.telemetry.scalarAdd( + "urlbar.persistedsearchterms.revert_by_popup_count", + 1 + ); + } + } + + /** + * Called by inputs that resemble search boxes, but actually hand input off + * to the Urlbar. We use these fake inputs on the new tab page and + * about:privatebrowsing. + * + * @param {string} searchString + * The search string to use. + * @param {nsISearchEngine} [searchEngine] + * Optional. If included and the right prefs are set, we will enter search + * mode when handing `searchString` from the fake input to the Urlbar. + * @param {string} newtabSessionId + * Optional. The id of the newtab session that handed off this search. + * + */ + handoff(searchString, searchEngine, newtabSessionId) { + this._isHandoffSession = true; + this._handoffSession = newtabSessionId; + if (lazy.UrlbarPrefs.get("shouldHandOffToSearchMode") && searchEngine) { + this.search(searchString, { + searchEngine, + searchModeEntry: "handoff", + }); + } else { + this.search(searchString); + } + } + + /** + * Called when an element of the view is picked. + * + * @param {Element} element The element that was picked. + * @param {Event} event The event that picked the element. + */ + pickElement(element, event) { + let result = this.view.getResultFromElement(element); + if (!result) { + return; + } + this.pickResult(result, event, element); + } + + /** + * Called when a result is picked. + * + * @param {UrlbarResult} result The result that was picked. + * @param {Event} event The event that picked the result. + * @param {DOMElement} element the picked view element, if available. + * @param {object} browser The browser to use for the load. + */ + // eslint-disable-next-line complexity + pickResult( + result, + event, + element = null, + browser = this.window.gBrowser.selectedBrowser + ) { + if (element?.classList.contains("urlbarView-button-menu")) { + this.view.openResultMenu(result, element); + return; + } + + let urlOverride; + if (element?.dataset.command) { + this.controller.engagementEvent.record(event, { + result, + element, + searchString: this._lastSearchString, + selType: + element.dataset.command == "help" && + result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP + ? "tiphelp" + : element.dataset.command, + }); + if (element.dataset.command == "help") { + urlOverride = result.payload.helpUrl; + } + urlOverride ||= element.dataset.url; + if (!urlOverride) { + return; + } + } + + // When a one-off is selected, we restyle heuristic results to look like + // search results. In the unlikely event that they are clicked, instead of + // picking the results as usual, we confirm search mode, same as if the user + // had selected them and pressed the enter key. Restyling results in this + // manner was agreed on as a compromise between consistent UX and + // engineering effort. See review discussion at bug 1667766. + if ( + result.heuristic && + this.searchMode?.isPreview && + this.view.oneOffSearchButtons.selectedButton + ) { + this.confirmSearchMode(); + this.search(this.value); + return; + } + + if ( + result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP && + result.payload.type == "dismissalAcknowledgment" + ) { + // The user clicked the "Got it" button inside the dismissal + // acknowledgment tip. Dismiss the tip. + this.controller.engagementEvent.record(event, { + result, + element, + searchString: this._lastSearchString, + selType: "dismiss", + }); + this.view.onQueryResultRemoved(result.rowIndex); + return; + } + + urlOverride ||= element?.dataset.url; + let originalUntrimmedValue = this.untrimmedValue; + let isCanonized = this.setValueFromResult({ result, event, urlOverride }); + let where = this._whereToOpen(event); + let openParams = { + allowInheritPrincipal: false, + globalHistoryOptions: { + triggeringSearchEngine: result.payload?.engine, + triggeringSponsoredURL: result.payload?.isSponsored + ? result.payload.url + : undefined, + }, + private: this.isPrivate, + }; + + if ( + urlOverride && + result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP && + where == "current" + ) { + // Open non-tip help links in a new tab unless the user held a modifier. + // TODO (bug 1696232): Do this for tip help links, too. + where = "tab"; + } + + if (!result.payload.providesSearchMode) { + this.view.close({ elementPicked: true }); + } + + this.controller.recordSelectedResult(event, result); + + if (isCanonized) { + this.controller.engagementEvent.record(event, { + result, + element, + selType: "canonized", + searchString: this._lastSearchString, + }); + this._loadURL(this.value, event, where, openParams, browser); + return; + } + + let { url, postData } = urlOverride + ? { url: urlOverride, postData: null } + : lazy.UrlbarUtils.getUrlFromResult(result); + openParams.postData = postData; + + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.URL: { + // Bug 1578856: both the provider and the docshell run heuristics to + // decide how to handle a non-url string, either fixing it to a url, or + // searching for it. + // Some preferences can control the docshell behavior, for example + // if dns_first_for_single_words is true, the docshell looks up the word + // against the dns server, and either loads it as an url or searches for + // it, depending on the lookup result. The provider instead will always + // return a fixed url in this case, because URIFixup is synchronous and + // can't do a synchronous dns lookup. A possible long term solution + // would involve sharing the docshell logic with the provider, along + // with the dns lookup. + // For now, in this specific case, we'll override the result's url + // with the input value, and let it pass through to _loadURL(), and + // finally to the docshell. + // This also means that in some cases the heuristic result will show a + // Visit entry, but the docshell will instead execute a search. It's a + // rare case anyway, most likely to happen for enterprises customizing + // the urifixup prefs. + if ( + result.heuristic && + lazy.UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && + lazy.UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) + ) { + url = originalUntrimmedValue; + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: { + // If this result comes from a bookmark keyword, let it inherit the + // current document's principal, otherwise bookmarklets would break. + openParams.allowInheritPrincipal = true; + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: { + if (this.hasAttribute("actionoverride")) { + where = "current"; + break; + } + + // Keep the searchMode for telemetry since handleRevert sets it to null. + const searchMode = this.searchMode; + this.handleRevert(); + let prevTab = this.window.gBrowser.selectedTab; + let loadOpts = { + adoptIntoActiveWindow: lazy.UrlbarPrefs.get( + "switchTabs.adoptIntoActiveWindow" + ), + }; + + // We cache the search string because switching tab may clear it. + let searchString = this._lastSearchString; + this.controller.engagementEvent.record(event, { + result, + element, + searchString, + searchMode, + selType: "tabswitch", + }); + + let switched = this.window.switchToTabHavingURI( + Services.io.newURI(url), + false, + loadOpts + ); + if (switched && prevTab.isEmpty) { + this.window.gBrowser.removeTab(prevTab); + } + + if (switched && !this.isPrivate && !result.heuristic) { + // We don't await for this, because a rejection should not interrupt + // the load. Just reportError it. + lazy.UrlbarUtils.addToInputHistory(url, searchString).catch( + console.error + ); + } + + return; + } + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: { + if (result.payload.providesSearchMode) { + this.controller.engagementEvent.record(event, { + result, + element, + searchString: this._lastSearchString, + selType: this.controller.engagementEvent.typeFromElement( + result, + element + ), + }); + this.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + }); + return; + } + + if ( + !this.searchMode && + result.heuristic && + // If we asked the DNS earlier, avoid the post-facto check. + !lazy.UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && + // TODO (bug 1642623): for now there is no smart heuristic to skip the + // DNS lookup, so any value above 0 will run it. + lazy.UrlbarPrefs.get("dnsResolveSingleWordsAfterSearch") > 0 && + this.window.gKeywordURIFixup && + lazy.UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) + ) { + // When fixing a single word to a search, the docShell would also + // query the DNS and if resolved ask the user whether they would + // rather visit that as a host. On a positive answer, it adds the host + // to the list that we use to make decisions. + // Because we are directly asking for a search here, bypassing the + // docShell, we need to do the same ourselves. + // See also URIFixupChild.jsm and keyword-uri-fixup. + let fixupInfo = this._getURIFixupInfo(originalUntrimmedValue.trim()); + if (fixupInfo) { + this.window.gKeywordURIFixup.check( + this.window.gBrowser.selectedBrowser, + fixupInfo + ); + } + } + + if (result.payload.inPrivateWindow) { + where = "window"; + openParams.private = true; + } + + const actionDetails = { + isSuggestion: !!result.payload.suggestion, + isFormHistory: + result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY, + alias: result.payload.keyword, + url, + }; + const engine = Services.search.getEngineByName(result.payload.engine); + this._recordSearch(engine, event, actionDetails); + + if (!result.payload.inPrivateWindow) { + lazy.UrlbarUtils.addToFormHistory( + this, + result.payload.suggestion || result.payload.query, + engine.name + ).catch(console.error); + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.TIP: { + let scalarName = + element.classList.contains("urlbarView-button-help") || + element.dataset.command == "help" + ? `${result.payload.type}-help` + : `${result.payload.type}-picked`; + Services.telemetry.keyedScalarAdd("urlbar.tips", scalarName, 1); + if (url) { + break; + } + this.handleRevert(); + this.controller.engagementEvent.record(event, { + result, + element, + selType: "tip", + searchString: this._lastSearchString, + }); + return; + } + case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC: { + if (url) { + break; + } + url = result.payload.url; + // Keep the searchMode for telemetry since handleRevert sets it to null. + const searchMode = this.searchMode; + // Do not revert the Urlbar if we're going to navigate. We want the URL + // populated so we can navigate to it. + if (!url || !result.payload.shouldNavigate) { + this.handleRevert(); + } + // If we won't be navigating, this is the end of the engagement. + if (!url || !result.payload.shouldNavigate) { + this.controller.engagementEvent.record(event, { + result, + element, + searchMode, + searchString: this._lastSearchString, + selType: this.controller.engagementEvent.typeFromElement( + result, + element + ), + }); + return; + } + break; + } + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: { + this.controller.engagementEvent.record(event, { + result, + element, + selType: "extension", + searchString: this._lastSearchString, + }); + + // The urlbar needs to revert to the loaded url when a command is + // handled by the extension. + this.handleRevert(); + // We don't directly handle a load when an Omnibox API result is picked, + // instead we forward the request to the WebExtension itself, because + // the value may not even be a url. + // We pass the keyword and content, that actually is the retrieved value + // prefixed by the keyword. ExtensionSearchHandler uses this keyword + // redundancy as a sanity check. + lazy.ExtensionSearchHandler.handleInputEntered( + result.payload.keyword, + result.payload.content, + where + ); + return; + } + } + + if (!url) { + throw new Error(`Invalid url for result ${JSON.stringify(result)}`); + } + + // Record input history but only in non-private windows. + if (!this.isPrivate) { + let input; + if (!result.heuristic) { + input = this._lastSearchString; + } else if (result.autofill?.type == "adaptive") { + input = result.autofill.adaptiveHistoryInput; + } + // `input` may be an empty string, so do a strict comparison here. + if (input !== undefined) { + // We don't await for this, because a rejection should not interrupt + // the load. Just reportError it. + lazy.UrlbarUtils.addToInputHistory(url, input).catch(console.error); + } + } + + this.controller.engagementEvent.record(event, { + result, + element, + searchString: this._lastSearchString, + selType: this.controller.engagementEvent.typeFromElement(result, element), + searchSource: this.getSearchSource(event), + }); + + if (result.payload.sendAttributionRequest) { + lazy.PartnerLinkAttribution.makeRequest({ + targetURL: result.payload.url, + source: "urlbar", + campaignID: Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ), + }); + if (!this.isPrivate && result.providerName === "UrlbarProviderTopSites") { + // The position is 1-based for telemetry + const position = result.rowIndex + 1; + Services.telemetry.keyedScalarAdd( + SCALAR_CATEGORY_TOPSITES, + `urlbar_${position}`, + 1 + ); + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + position, + source: "urlbar", + tile_id: result.payload.sponsoredTileId || -1, + reporting_url: result.payload.sponsoredClickUrl, + advertiser: result.payload.title.toLocaleLowerCase(), + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_SELECTION + ); + } + } + + this._loadURL( + url, + event, + where, + openParams, + { + source: result.source, + type: result.type, + searchTerm: result.payload.suggestion ?? result.payload.query, + }, + browser + ); + } + + /** + * Called by the view when moving through results with the keyboard, and when + * picking a result. This sets the input value to the value of the result and + * invalidates the pageproxystate. It also sets the result that is associated + * with the current input value. If you need to set this result but don't + * want to also set the input value, then use setResultForCurrentValue. + * + * @param {object} options + * Options. + * @param {UrlbarResult} [options.result] + * The result that was selected or picked, null if no result was selected. + * @param {Event} [options.event] + * The event that picked the result. + * @param {string} [options.urlOverride] + * Normally the URL is taken from `result.payload.url`, but if `urlOverride` + * is specified, it's used instead. + * @returns {boolean} + * Whether the value has been canonized + */ + setValueFromResult({ result = null, event = null, urlOverride = null } = {}) { + // Usually this is set by a previous input event, but in certain cases, like + // when opening Top Sites on a loaded page, it wouldn't happen. To avoid + // confusing the user, we always enforce it when a result changes our value. + this.setPageProxyState("invalid", true); + + // A previous result may have previewed search mode. If we don't expect that + // we might stay in a search mode of some kind, exit it now. + if ( + this.searchMode?.isPreview && + !result?.payload.providesSearchMode && + !this.view.oneOffSearchButtons.selectedButton + ) { + this.searchMode = null; + } + + if (!result) { + // This happens when there's no selection, for example when moving to the + // one-offs search settings button, or to the input field when Top Sites + // are shown; then we must reset the input value. + // Note that for Top Sites the last search string would be empty, thus we + // must restore the last text value. + // Note that unselected autofill results will still arrive in this + // function with a non-null `result`. They are handled below. + this.value = this._lastSearchString || this._valueOnLastSearch; + this.setResultForCurrentValue(result); + return false; + } + + // The value setter clobbers the actiontype attribute, so we need this + // helper to restore it afterwards. + const setValueAndRestoreActionType = (value, allowTrim) => { + this._setValue(value, allowTrim); + + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + this.setAttribute("actiontype", "switchtab"); + break; + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + this.setAttribute("actiontype", "extension"); + break; + } + }; + + // For autofilled results, the value that should be canonized is not the + // autofilled value but the value that the user typed. + let canonizedUrl = this._maybeCanonizeURL( + event, + result.autofill ? this._lastSearchString : this.value + ); + if (canonizedUrl) { + setValueAndRestoreActionType(canonizedUrl, true); + this.setResultForCurrentValue(result); + return true; + } + + if (result.autofill) { + this._autofillValue(result.autofill); + } + + if (result.payload.providesSearchMode) { + let enteredSearchMode; + // Only preview search mode if the result is selected. + if (this.view.resultIsSelected(result)) { + // Not starting a query means we will only preview search mode. + enteredSearchMode = this.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + startQuery: false, + }); + } + if (!enteredSearchMode) { + setValueAndRestoreActionType(this._getValueFromResult(result), true); + this.searchMode = null; + } + this.setResultForCurrentValue(result); + return false; + } + + // If the url is trimmed but it's invalid (for example it has an unknown + // single word host, or an unknown domain suffix), trimming + // it would end up executing a search instead of visiting it. + let allowTrim = true; + if ( + (urlOverride || result.type == lazy.UrlbarUtils.RESULT_TYPE.URL) && + lazy.UrlbarPrefs.get("trimURLs") + ) { + let url = urlOverride || result.payload.url; + if (url.startsWith(lazy.BrowserUIUtils.trimURLProtocol)) { + let fixupInfo = this._getURIFixupInfo(lazy.BrowserUIUtils.trimURL(url)); + if (fixupInfo?.keywordAsSent) { + allowTrim = false; + } + } + } + + if (!result.autofill) { + setValueAndRestoreActionType( + this._getValueFromResult(result, urlOverride), + allowTrim + ); + } + + this.setResultForCurrentValue(result); + return false; + } + + /** + * The input keeps track of the result associated with the current input + * value. This result can be set by calling either setValueFromResult or this + * method. Use this method when you need to set the result without also + * setting the input value. This can be the case when either the selection is + * cleared and no other result becomes selected, or when the result is the + * heuristic and we don't want to modify the value the user is typing. + * + * @param {UrlbarResult} result + * The result to associate with the current input value. + */ + setResultForCurrentValue(result) { + this._resultForCurrentValue = result; + } + + /** + * Called by the controller when the first result of a new search is received. + * If it's an autofill result, then it may need to be autofilled, subject to a + * few restrictions. + * + * @param {UrlbarResult} result + * The first result. + */ + _autofillFirstResult(result) { + if (!result.autofill) { + return; + } + + let isPlaceholderSelected = + this._autofillPlaceholder && + this.selectionEnd == this._autofillPlaceholder.value.length && + this.selectionStart == this._lastSearchString.length && + this._autofillPlaceholder.value + .toLocaleLowerCase() + .startsWith(this._lastSearchString.toLocaleLowerCase()); + + // Don't autofill if there's already a selection (with one caveat described + // next) or the cursor isn't at the end of the input. But if there is a + // selection and it's the autofill placeholder value, then do autofill. + if ( + !isPlaceholderSelected && + !this._autofillIgnoresSelection && + (this.selectionStart != this.selectionEnd || + this.selectionEnd != this._lastSearchString.length) + ) { + return; + } + + this.setValueFromResult({ result }); + } + + /** + * Invoked by the controller when the first result is received. + * + * @param {UrlbarResult} firstResult + * The first result received. + * @returns {boolean} + * True if this method canceled the query and started a new one. False + * otherwise. + */ + onFirstResult(firstResult) { + // If the heuristic result has a keyword but isn't a keyword offer, we may + // need to enter search mode. + if ( + firstResult.heuristic && + firstResult.payload.keyword && + !firstResult.payload.providesSearchMode && + this.maybeConfirmSearchModeFromResult({ + result: firstResult, + entry: "typed", + checkValue: false, + }) + ) { + return true; + } + + // To prevent selection flickering, we apply autofill on input through a + // placeholder, without waiting for results. But, if the first result is + // not an autofill one, the autofill prediction was wrong and we should + // restore the original user typed string. + if (firstResult.autofill) { + this._autofillFirstResult(firstResult); + } else if ( + this._autofillPlaceholder && + // Avoid clobbering added spaces (for token aliases, for example). + !this.value.endsWith(" ") + ) { + this._autofillPlaceholder = null; + this._setValue(this.window.gBrowser.userTypedValue, false); + } + + return false; + } + + /** + * Starts a query based on the current input value. + * + * @param {object} [options] + * Object options + * @param {boolean} [options.allowAutofill] + * Whether or not to allow providers to include autofill results. + * @param {boolean} [options.autofillIgnoresSelection] + * Normally we autofill only if the cursor is at the end of the string, + * if this is set we'll autofill regardless of selection. + * @param {string} [options.searchString] + * The search string. If not given, the current input value is used. + * Otherwise, the current input value must start with this value. + * @param {boolean} [options.resetSearchState] + * If this is the first search of a user interaction with the input, set + * this to true (the default) so that search-related state from the previous + * interaction doesn't interfere with the new interaction. Otherwise set it + * to false so that state is maintained during a single interaction. The + * intended use for this parameter is that it should be set to false when + * this method is called due to input events. + * @param {event} [options.event] + * The user-generated event that triggered the query, if any. If given, we + * will record engagement event telemetry for the query. + */ + startQuery({ + allowAutofill = true, + autofillIgnoresSelection = false, + searchString = null, + resetSearchState = true, + event = null, + } = {}) { + if (!searchString) { + searchString = + this.getAttribute("pageproxystate") == "valid" ? "" : this.value; + } else if (!this.value.startsWith(searchString)) { + throw new Error("The current value doesn't start with the search string"); + } + + if (event) { + this.controller.engagementEvent.start(event, searchString); + } + + if (this._suppressStartQuery) { + return; + } + + this._autofillIgnoresSelection = autofillIgnoresSelection; + if (resetSearchState) { + this._resetSearchState(); + } + + this._lastSearchString = searchString; + this._valueOnLastSearch = this.value; + + let options = { + allowAutofill, + isPrivate: this.isPrivate, + maxResults: lazy.UrlbarPrefs.get("maxRichResults"), + searchString, + view: this.view, + userContextId: + this.window.gBrowser.selectedBrowser.getAttribute("usercontextid"), + currentPage: this.window.gBrowser.currentURI.spec, + formHistoryName: this.formHistoryName, + prohibitRemoteResults: + event && + lazy.UrlbarUtils.isPasteEvent(event) && + lazy.UrlbarPrefs.get("maxCharsForSearchSuggestions") < + event.data?.length, + }; + + if (this.searchMode) { + this.confirmSearchMode(); + options.searchMode = this.searchMode; + if (this.searchMode.source) { + options.sources = [this.searchMode.source]; + } + } + + // TODO (Bug 1522902): This promise is necessary for tests, because some + // tests are not listening for completion when starting a query through + // other methods than startQuery (input events for example). + this.lastQueryContextPromise = this.controller.startQuery( + new lazy.UrlbarQueryContext(options) + ); + } + + /** + * Sets the input's value, starts a search, and opens the view. + * + * @param {string} value + * The input's value will be set to this value, and the search will + * use it as its query. + * @param {object} [options] + * Object options + * @param {nsISearchEngine} [options.searchEngine] + * Search engine to use when the search is using a known alias. + * @param {UrlbarUtils.SEARCH_MODE_ENTRY} [options.searchModeEntry] + * If provided, we will record this parameter as the search mode entry point + * in Telemetry. Consumers should provide this if they expect their call + * to enter search mode. + * @param {boolean} [options.focus] + * If true, the urlbar will be focused. If false, the focus will remain + * unchanged. + */ + search(value, { searchEngine, searchModeEntry, focus = true } = {}) { + if (focus) { + this.focus(); + } + let trimmedValue = value.trim(); + let end = trimmedValue.search(lazy.UrlbarTokenizer.REGEXP_SPACES); + let firstToken = end == -1 ? trimmedValue : trimmedValue.substring(0, end); + // Enter search mode if the string starts with a restriction token. + let searchMode = lazy.UrlbarUtils.searchModeForToken(firstToken); + let firstTokenIsRestriction = !!searchMode; + if (!searchMode && searchEngine) { + searchMode = { engineName: searchEngine.name }; + firstTokenIsRestriction = searchEngine.aliases.includes(firstToken); + } + + if (searchMode) { + searchMode.entry = searchModeEntry; + this.searchMode = searchMode; + if (firstTokenIsRestriction) { + // Remove the restriction token/alias from the string to be searched for + // in search mode. + value = value.replace(firstToken, ""); + } + if (lazy.UrlbarTokenizer.REGEXP_SPACES.test(value[0])) { + // If there was a trailing space after the restriction token/alias, + // remove it. + value = value.slice(1); + } + this._revertOnBlurValue = value; + } else if ( + Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(firstToken) + ) { + this.searchMode = null; + // If the entire value is a restricted token, append a space. + if (Object.values(lazy.UrlbarTokenizer.RESTRICT).includes(value)) { + value += " "; + } + this._revertOnBlurValue = value; + } + this.inputField.value = value; + // Avoid selecting the text if this method is called twice in a row. + this.selectionStart = -1; + + // Note: proper IME Composition handling depends on the fact this generates + // an input event, rather than directly invoking the controller; everything + // goes through _on_input, that will properly skip the search until the + // composition is committed. _on_input also skips the search when it's the + // same as the previous search, but we want to allow consecutive searches + // with the same string. So clear _lastSearchString first. + this._lastSearchString = ""; + let event = new UIEvent("input", { + bubbles: true, + cancelable: false, + view: this.window, + detail: 0, + }); + this.inputField.dispatchEvent(event); + } + + /** + * Focus without the focus styles. + * This is used by Activity Stream and about:privatebrowsing for search hand-off. + */ + setHiddenFocus() { + this._hideFocus = true; + if (this.focused) { + this.removeAttribute("focused"); + } else { + this.focus(); + } + } + + /** + * Restore focus styles. + * This is used by Activity Stream and about:privatebrowsing for search hand-off. + * + * @param {Browser} forceSuppressFocusBorder + * Set true to suppress-focus-border attribute if this flag is true. + */ + removeHiddenFocus(forceSuppressFocusBorder = false) { + this._hideFocus = false; + if (this.focused) { + this.setAttribute("focused", "true"); + + if (forceSuppressFocusBorder) { + this.toggleAttribute("suppress-focus-border", true); + } + } + } + + /** + * Gets the search mode for a specific browser instance. + * + * @param {Browser} browser + * The search mode for this browser will be returned. + * @param {boolean} [confirmedOnly] + * Normally, if the browser has both preview and confirmed modes, preview + * mode will be returned since it takes precedence. If this argument is + * true, then only confirmed search mode will be returned, or null if + * search mode hasn't been confirmed. + * @returns {object} + * A search mode object. See setSearchMode documentation. If the browser + * is not in search mode, then null is returned. + */ + getSearchMode(browser, confirmedOnly = false) { + let modes = this._searchModesByBrowser.get(browser); + + // Return copies so that callers don't modify the stored values. + if (!confirmedOnly && modes?.preview) { + return { ...modes.preview }; + } + if (modes?.confirmed) { + return { ...modes.confirmed }; + } + return null; + } + + /** + * Sets search mode for a specific browser instance. If the given browser is + * selected, then this will also enter search mode. + * + * @param {object} searchMode + * A search mode object. + * @param {string} searchMode.engineName + * The name of the search engine to restrict to. + * @param {UrlbarUtils.RESULT_SOURCE} searchMode.source + * A result source to restrict to. + * @param {string} searchMode.entry + * How search mode was entered. This is recorded in event telemetry. One of + * the values in UrlbarUtils.SEARCH_MODE_ENTRY. + * @param {boolean} [searchMode.isPreview] + * If true, we will preview search mode. Search mode preview does not record + * telemetry and has slighly different UI behavior. The preview is exited in + * favor of full search mode when a query is executed. False should be + * passed if the caller needs to enter search mode but expects it will not + * be interacted with right away. Defaults to true. + * @param {Browser} browser + * The browser for which to set search mode. + */ + async setSearchMode(searchMode, browser) { + let currentSearchMode = this.getSearchMode(browser); + let areSearchModesSame = + (!currentSearchMode && !searchMode) || + lazy.ObjectUtils.deepEqual(currentSearchMode, searchMode); + + // Exit search mode if the passed-in engine is invalid or hidden. + let engine; + if (searchMode?.engineName) { + if (!Services.search.isInitialized) { + await Services.search.init(); + } + engine = Services.search.getEngineByName(searchMode.engineName); + if (!engine || engine.hidden) { + searchMode = null; + } + } + + let { engineName, source, entry, isPreview = true } = searchMode || {}; + + searchMode = null; + + if (engineName) { + searchMode = { + engineName, + isGeneralPurposeEngine: engine.isGeneralPurposeEngine, + }; + if (source) { + searchMode.source = source; + } else if (searchMode.isGeneralPurposeEngine) { + // History results for general-purpose search engines are often not + // useful, so we hide them in search mode. See bug 1658646 for + // discussion. + searchMode.source = lazy.UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } else if (source) { + let sourceName = lazy.UrlbarUtils.getResultSourceName(source); + if (sourceName) { + searchMode = { source }; + } else { + console.error(`Unrecognized source: ${source}`); + } + } + + if (searchMode) { + searchMode.isPreview = isPreview; + if (lazy.UrlbarUtils.SEARCH_MODE_ENTRY.has(entry)) { + searchMode.entry = entry; + } else { + // If we see this value showing up in telemetry, we should review + // search mode's entry points. + searchMode.entry = "other"; + } + + // Add the search mode to the map. + if (!searchMode.isPreview) { + this._searchModesByBrowser.set(browser, { + confirmed: searchMode, + }); + } else { + let modes = this._searchModesByBrowser.get(browser) || {}; + modes.preview = searchMode; + this._searchModesByBrowser.set(browser, modes); + } + } else { + this._searchModesByBrowser.delete(browser); + } + + // Enter search mode if the browser is selected. + if (browser == this.window.gBrowser.selectedBrowser) { + this._updateSearchModeUI(searchMode); + if (searchMode) { + // Set userTypedValue to the query string so that it's properly restored + // when switching back to the current tab and across sessions. + this.window.gBrowser.userTypedValue = this.untrimmedValue; + this.valueIsTyped = true; + if (!searchMode.isPreview && !areSearchModesSame) { + try { + lazy.BrowserSearchTelemetry.recordSearchMode(searchMode); + } catch (ex) { + console.error(ex); + } + } + } + } + } + + /** + * Restores the current browser search mode from a previously stored state. + */ + restoreSearchModeState() { + let modes = this._searchModesByBrowser.get( + this.window.gBrowser.selectedBrowser + ); + this.searchMode = modes?.confirmed; + } + + /** + * Enters search mode with the default engine. + */ + searchModeShortcut() { + // We restrict to search results when entering search mode from this + // shortcut to honor historical behaviour. + this.searchMode = { + source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: lazy.UrlbarSearchUtils.getDefaultEngine(this.isPrivate)?.name, + entry: "shortcut", + }; + // The searchMode setter clears the input if pageproxystate is valid, so + // we know at this point this.value will either be blank or the user's + // typed string. + this.search(this.value); + this.select(); + } + + /** + * Confirms the current search mode. + */ + confirmSearchMode() { + let searchMode = this.searchMode; + if (searchMode?.isPreview) { + searchMode.isPreview = false; + this.searchMode = searchMode; + + // Unselect the one-off search button to ensure UI consistency. + this.view.oneOffSearchButtons.selectedButton = null; + } + } + + // Getters and Setters below. + + get editor() { + return this.inputField.editor; + } + + get focused() { + return this.document.activeElement == this.inputField; + } + + get goButton() { + return this.querySelector("#urlbar-go-button"); + } + + get value() { + return this.inputField.value; + } + + get untrimmedValue() { + return this._untrimmedValue; + } + + set value(val) { + this._setValue(val, true); + } + + get lastSearchString() { + return this._lastSearchString; + } + + get searchMode() { + return this.getSearchMode(this.window.gBrowser.selectedBrowser); + } + + set searchMode(searchMode) { + this.setSearchMode(searchMode, this.window.gBrowser.selectedBrowser); + } + + async updateLayoutBreakout() { + if (!this._toolbar) { + // Expanding requires a parent toolbar. + return; + } + if (this.document.fullscreenElement) { + // Toolbars are hidden in DOM fullscreen mode, so we can't get proper + // layout information and need to retry after leaving that mode. + this.window.addEventListener( + "fullscreen", + () => { + this.updateLayoutBreakout(); + }, + { once: true } + ); + return; + } + await this._updateLayoutBreakoutDimensions(); + } + + startLayoutExtend() { + // Do not expand if: + // The Urlbar does not support being expanded or it is already expanded + if ( + !this.hasAttribute("breakout") || + this.hasAttribute("breakout-extend") + ) { + return; + } + if (!this.view.isOpen) { + return; + } + + if (Cu.isInAutomation) { + if (lazy.UrlbarPrefs.get("disableExtendForTests")) { + this.setAttribute("breakout-extend-disabled", "true"); + return; + } + this.removeAttribute("breakout-extend-disabled"); + } + + this._toolbar.setAttribute("urlbar-exceeds-toolbar-bounds", "true"); + this.setAttribute("breakout-extend", "true"); + + // Enable the animation only after the first extend call to ensure it + // doesn't run when opening a new window. + if (!this.hasAttribute("breakout-extend-animate")) { + this.window.promiseDocumentFlushed(() => { + this.window.requestAnimationFrame(() => { + this.setAttribute("breakout-extend-animate", "true"); + }); + }); + } + } + + endLayoutExtend() { + // If reduce motion is enabled, we want to collapse the Urlbar here so the + // user sees only sees two states: not expanded, and expanded with the view + // open. + if (!this.hasAttribute("breakout-extend") || this.view.isOpen) { + return; + } + + this.removeAttribute("breakout-extend"); + this._toolbar.removeAttribute("urlbar-exceeds-toolbar-bounds"); + } + + /** + * Updates the user interface to indicate whether the URI in the address bar + * is different than the loaded page, because it's being edited or because a + * search result is currently selected and is displayed in the location bar. + * + * @param {string} state + * The string "valid" indicates that the security indicators and other + * related user interface elments should be shown because the URI in + * the location bar matches the loaded page. The string "invalid" + * indicates that the URI in the location bar is different than the + * loaded page. + * @param {boolean} [updatePopupNotifications] + * Indicates whether we should update the PopupNotifications + * visibility due to this change, otherwise avoid doing so as it is + * being handled somewhere else. + */ + setPageProxyState(state, updatePopupNotifications) { + let prevState = this.getAttribute("pageproxystate"); + + this.setAttribute("pageproxystate", state); + this._inputContainer.setAttribute("pageproxystate", state); + this._identityBox.setAttribute("pageproxystate", state); + + if (state == "valid") { + this._lastValidURLStr = this.value; + } + + if ( + updatePopupNotifications && + prevState != state && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + } + + /** + * When switching tabs quickly, TabSelect sometimes happens before + * _adjustFocusAfterTabSwitch and due to the focus still being on the old + * tab, we end up flickering the results pane briefly. + */ + afterTabSwitchFocusChange() { + this._gotFocusChange = true; + this._afterTabSelectAndFocusChange(); + } + + /** + * Confirms search mode and starts a new search if appropriate for the given + * result. See also _searchModeForResult. + * + * @param {object} options + * Options object. + * @param {string} options.entry + * The search mode entry point. See setSearchMode documentation for details. + * @param {UrlbarResult} [options.result] + * The result to confirm. Defaults to the currently selected result. + * @param {boolean} [options.checkValue] + * If true, the trimmed input value must equal the result's keyword in order + * to enter search mode. + * @param {boolean} [options.startQuery] + * If true, start a query after entering search mode. Defaults to true. + * @returns {boolean} + * True if we entered search mode and false if not. + */ + maybeConfirmSearchModeFromResult({ + entry, + result = this._resultForCurrentValue, + checkValue = true, + startQuery = true, + }) { + if ( + !result || + (checkValue && this.value.trim() != result.payload.keyword?.trim()) + ) { + return false; + } + + let searchMode = this._searchModeForResult(result, entry); + if (!searchMode) { + return false; + } + + this.searchMode = searchMode; + + let value = result.payload.query?.trimStart() || ""; + this._setValue(value, false); + + if (startQuery) { + this.startQuery({ allowAutofill: false }); + } + + return true; + } + + observe(subject, topic, data) { + switch (topic) { + case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED: { + switch (data) { + case lazy.SearchUtils.MODIFIED_TYPE.CHANGED: + case lazy.SearchUtils.MODIFIED_TYPE.REMOVED: { + let searchMode = this.searchMode; + let engine = subject.QueryInterface(Ci.nsISearchEngine); + if (searchMode?.engineName == engine.name) { + // Exit search mode if the current search mode engine was removed. + this.searchMode = searchMode; + } + break; + } + } + break; + } + } + } + + /** + * Get search source. + * + * @param {Event} event + * The event that triggered this query. + * @returns {string} + * The source name. + */ + getSearchSource(event) { + if (this._isHandoffSession) { + return "urlbar-handoff"; + } + + const isOneOff = this.view.oneOffSearchButtons.eventTargetIsAOneOff(event); + if (this.searchMode && !isOneOff) { + // Without checking !isOneOff, we might record the string + // oneoff_urlbar-searchmode in the SEARCH_COUNTS probe (in addition to + // oneoff_urlbar and oneoff_searchbar). The extra information is not + // necessary; the intent is the same regardless of whether the user is + // in search mode when they do a key-modified click/enter on a one-off. + return "urlbar-searchmode"; + } + + if (this.window.gBrowser.selectedBrowser.searchTerms && !isOneOff) { + return "urlbar-persisted"; + } + + return "urlbar"; + } + + // Private methods below. + + _addObservers() { + Services.obs.addObserver( + this, + lazy.SearchUtils.TOPIC_ENGINE_MODIFIED, + true + ); + } + + _getURIFixupInfo(searchString) { + let flags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (this.isPrivate) { + flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + try { + return Services.uriFixup.getFixupURIInfo(searchString, flags); + } catch (ex) { + console.error( + `An error occured while trying to fixup "${searchString}": ${ex}` + ); + } + return null; + } + + _afterTabSelectAndFocusChange() { + // We must have seen both events to proceed safely. + if (!this._gotFocusChange || !this._gotTabSelect) { + return; + } + this._gotFocusChange = this._gotTabSelect = false; + + this._resetSearchState(); + + // Switching tabs doesn't always change urlbar focus, so we must try to + // reopen here too, not just on focus. + // We don't use the original TabSelect event because caching it causes + // leaks on MacOS. + if (this.view.autoOpen({ event: new CustomEvent("tabswitch") })) { + return; + } + // The input may retain focus when switching tabs in which case we + // need to close the view explicitly. + this.view.close(); + } + + async _updateLayoutBreakoutDimensions() { + // When this method gets called a second time before the first call + // finishes, we need to disregard the first one. + let updateKey = {}; + this._layoutBreakoutUpdateKey = updateKey; + + this.removeAttribute("breakout"); + this.textbox.parentNode.removeAttribute("breakout"); + + await this.window.promiseDocumentFlushed(() => {}); + await new Promise(resolve => { + this.window.requestAnimationFrame(() => { + if (this._layoutBreakoutUpdateKey != updateKey) { + return; + } + + this.textbox.parentNode.style.setProperty( + "--urlbar-container-height", + px(getBoundsWithoutFlushing(this.textbox.parentNode).height) + ); + this.textbox.style.setProperty( + "--urlbar-height", + px(getBoundsWithoutFlushing(this.textbox).height) + ); + this.textbox.style.setProperty( + "--urlbar-toolbar-height", + px(getBoundsWithoutFlushing(this._toolbar).height) + ); + + this.setAttribute("breakout", "true"); + this.textbox.parentNode.setAttribute("breakout", "true"); + + resolve(); + }); + }); + } + + _setValue(val, allowTrim) { + // Don't expose internal about:reader URLs to the user. + let originalUrl = lazy.ReaderMode.getOriginalUrlObjectForDisplay(val); + if (originalUrl) { + val = originalUrl.displaySpec; + } + this._untrimmedValue = val; + + if (allowTrim) { + val = this._trimValue(val); + } + + this.valueIsTyped = false; + this._resultForCurrentValue = null; + this.inputField.value = val; + this.formatValue(); + this.removeAttribute("actiontype"); + + // Dispatch ValueChange event for accessibility. + let event = this.document.createEvent("Events"); + event.initEvent("ValueChange", true, true); + this.inputField.dispatchEvent(event); + + return val; + } + + _getValueFromResult(result, urlOverride = null) { + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + return result.payload.input; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: { + let value = ""; + if (result.payload.keyword) { + value += result.payload.keyword + " "; + } + value += result.payload.suggestion || result.payload.query; + return value; + } + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + return result.payload.content; + case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC: + return result.payload.input || ""; + } + + if (urlOverride === "") { + // Allow callers to clear the input. + return ""; + } + + try { + let uri = Services.io.newURI(urlOverride || result.payload.url); + if (uri) { + return losslessDecodeURI(uri); + } + } catch (ex) {} + + return ""; + } + + /** + * Resets some state so that searches from the user's previous interaction + * with the input don't interfere with searches from a new interaction. + */ + _resetSearchState() { + this._lastSearchString = this.value; + this._autofillPlaceholder = null; + } + + /** + * Autofills the autofill placeholder string if appropriate, and determines + * whether autofill should be allowed for the new search started by an input + * event. + * + * @param {string} value + * The new search string. + * @returns {boolean} + * Whether autofill should be allowed in the new search. + */ + _maybeAutofillPlaceholder(value) { + // We allow autofill in local but not remote search modes. + let allowAutofill = + this.selectionEnd == value.length && + !this.searchMode?.engineName && + this.searchMode?.source != lazy.UrlbarUtils.RESULT_SOURCE.SEARCH; + if (!allowAutofill) { + this._autofillPlaceholder = null; + return false; + } + + // Determine whether we can autofill the placeholder. The placeholder is a + // value that we autofill now, when the search starts and before we wait on + // its first result, in order to prevent a flicker in the input caused by + // the previous autofilled substring disappearing and reappearing when the + // first result arrives. Of course we can only autofill the placeholder if + // it starts with the new search string, and we shouldn't autofill anything + // if the caret isn't at the end of the input. + let canAutofillPlaceholder = false; + if (this._autofillPlaceholder) { + if (this._autofillPlaceholder.type == "adaptive") { + canAutofillPlaceholder = + value.length >= + this._autofillPlaceholder.adaptiveHistoryInput.length && + this._autofillPlaceholder.value + .toLocaleLowerCase() + .startsWith(value.toLocaleLowerCase()); + } else { + canAutofillPlaceholder = lazy.UrlbarUtils.canAutofillURL( + this._autofillPlaceholder.value, + value + ); + } + } + + if (!canAutofillPlaceholder) { + this._autofillPlaceholder = null; + } else if ( + this._autofillPlaceholder && + this.selectionEnd == this.value.length && + this._enableAutofillPlaceholder + ) { + let autofillValue = + value + this._autofillPlaceholder.value.substring(value.length); + this._autofillValue({ + value: autofillValue, + selectionStart: value.length, + selectionEnd: autofillValue.length, + type: this._autofillPlaceholder.type, + adaptiveHistoryInput: this._autofillPlaceholder.adaptiveHistoryInput, + }); + } + + return true; + } + + _checkForRtlText(value) { + let directionality = this.window.windowUtils.getDirectionFromText(value); + if (directionality == this.window.windowUtils.DIRECTION_RTL) { + return true; + } + return false; + } + + /** + * Invoked on overflow/underflow/scrollend events to update attributes + * related to the input text directionality. Overflow fade masks use these + * attributes to appear at the proper side of the urlbar. + */ + updateTextOverflow() { + if (!this._overflowing) { + this.removeAttribute("textoverflow"); + return; + } + + let isRTL = + this.getAttribute("domaindir") === "rtl" && + this._checkForRtlText(this.value); + + this.window.promiseDocumentFlushed(() => { + // Check overflow again to ensure it didn't change in the meanwhile. + let input = this.inputField; + if (input && this._overflowing) { + // Normally we would overflow at the final side of text direction, + // though RTL domains may cause us to overflow at the opposite side. + // This happens dynamically as a consequence of the input field contents + // and the call to _ensureFormattedHostVisible, this code only reports + // the final state of all that scrolling into an attribute, because + // there's no other way to capture this in css. + // Note it's also possible to scroll an unfocused input field using + // SHIFT + mousewheel on Windows, or with just the mousewheel / touchpad + // scroll (without modifiers) on Mac. + let side = "both"; + if (isRTL) { + if (input.scrollLeft == 0) { + side = "left"; + } else if (input.scrollLeft == input.scrollLeftMin) { + side = "right"; + } + } else if (input.scrollLeft == 0) { + side = "right"; + } else if (input.scrollLeft == input.scrollLeftMax) { + side = "left"; + } + + this.window.requestAnimationFrame(() => { + // And check once again, since we might have stopped overflowing + // since the promiseDocumentFlushed callback fired. + if (this._overflowing) { + this.setAttribute("textoverflow", side); + } + }); + } + }); + } + + _updateUrlTooltip() { + if (this.focused || !this._overflowing) { + this.inputField.removeAttribute("title"); + } else { + this.inputField.setAttribute("title", this.untrimmedValue); + } + } + + _getSelectedValueForClipboard() { + let selection = this.editor.selection; + const flags = + Ci.nsIDocumentEncoder.OutputPreformatted | + Ci.nsIDocumentEncoder.OutputRaw; + let selectedVal = selection.toStringWithFormat("text/plain", flags, 0); + + // Handle multiple-range selection as a string for simplicity. + if (selection.rangeCount > 1) { + return selectedVal; + } + + // If the selection doesn't start at the beginning or doesn't span the + // full domain or the URL bar is modified or there is no text at all, + // nothing else to do here. + if (this.selectionStart > 0 || this.valueIsTyped || selectedVal == "") { + return selectedVal; + } + + // The selection doesn't span the full domain if it doesn't contain a slash and is + // followed by some character other than a slash. + if (!selectedVal.includes("/")) { + let remainder = this.value.replace(selectedVal, ""); + if (remainder != "" && remainder[0] != "/") { + return selectedVal; + } + } + + let uri; + if (this.getAttribute("pageproxystate") == "valid") { + uri = this.window.gBrowser.currentURI; + } else { + // The value could be: + // 1. a trimmed url, set by selecting a result + // 2. a search string set by selecting a result + // 3. a url that was confirmed but didn't finish loading yet + // If it's an url the untrimmedValue should resolve to a valid URI, + // otherwise it's a search string that should be copied as-is. + try { + uri = Services.io.newURI(this._untrimmedValue); + } catch (ex) { + return selectedVal; + } + } + uri = this.makeURIReadable(uri); + let displaySpec = uri.displaySpec; + + // If the entire URL is selected, just use the actual loaded URI, + // unless we want a decoded URI, or it's a data: or javascript: URI, + // since those are hard to read when encoded. + if ( + this.value == selectedVal && + !uri.schemeIs("javascript") && + !uri.schemeIs("data") && + !lazy.UrlbarPrefs.get("decodeURLsOnCopy") + ) { + return displaySpec; + } + + // Just the beginning of the URL is selected, or we want a decoded + // url. First check for a trimmed value. + + if ( + !selectedVal.startsWith(lazy.BrowserUIUtils.trimURLProtocol) && + // Note _trimValue may also trim a trailing slash, thus we can't just do + // a straight string compare to tell if the protocol was trimmed. + !displaySpec.startsWith(this._trimValue(displaySpec)) + ) { + selectedVal = lazy.BrowserUIUtils.trimURLProtocol + selectedVal; + } + + // If selection starts from the beginning and part or all of the URL + // is selected, we check for decoded characters and encode them. + // Unless decodeURLsOnCopy is set. Do not encode data: URIs. + if (!lazy.UrlbarPrefs.get("decodeURLsOnCopy") && !uri.schemeIs("data")) { + try { + new URL(selectedVal); + // Use encodeURI instead of URL.href because we don't want + // trailing slash. + selectedVal = encodeURI(selectedVal); + } catch (ex) { + // URL is invalid. Return original selected value. + } + } + + return selectedVal; + } + + _toggleActionOverride(event) { + // Ignore repeated KeyboardEvents. + if (event.repeat) { + return; + } + if ( + event.keyCode == KeyEvent.DOM_VK_SHIFT || + event.keyCode == KeyEvent.DOM_VK_ALT || + event.keyCode == + (AppConstants.platform == "macosx" + ? KeyEvent.DOM_VK_META + : KeyEvent.DOM_VK_CONTROL) + ) { + if (event.type == "keydown") { + this._actionOverrideKeyCount++; + this.setAttribute("actionoverride", "true"); + this.view.panel.setAttribute("actionoverride", "true"); + } else if ( + this._actionOverrideKeyCount && + --this._actionOverrideKeyCount == 0 + ) { + this._clearActionOverride(); + } + } + } + + _clearActionOverride() { + this._actionOverrideKeyCount = 0; + this.removeAttribute("actionoverride"); + this.view.panel.removeAttribute("actionoverride"); + } + + /** + * Get the url to load for the search query and records in telemetry that it + * is being loaded. + * + * @param {nsISearchEngine} engine + * The engine to generate the query for. + * @param {Event} event + * The event that triggered this query. + * @param {object} searchActionDetails + * The details associated with this search query. + * @param {boolean} searchActionDetails.isSuggestion + * True if this query was initiated from a suggestion from the search engine. + * @param {boolean} searchActionDetails.alias + * True if this query was initiated via a search alias. + * @param {boolean} searchActionDetails.isFormHistory + * True if this query was initiated from a form history result. + * @param {string} searchActionDetails.url + * The url this query was triggered with. + */ + _recordSearch(engine, event, searchActionDetails = {}) { + const isOneOff = this.view.oneOffSearchButtons.eventTargetIsAOneOff(event); + + lazy.BrowserSearchTelemetry.recordSearch( + this.window.gBrowser.selectedBrowser, + engine, + this.getSearchSource(event), + { + ...searchActionDetails, + isOneOff, + newtabSessionId: this._handoffSession, + } + ); + } + + /** + * Shortens the given value, usually by removing http:// and trailing slashes. + * + * @param {string} val + * The string to be trimmed if it appears to be URI + * @returns {string} + * The trimmed string + */ + _trimValue(val) { + let trimmedValue = lazy.UrlbarPrefs.get("trimURLs") + ? lazy.BrowserUIUtils.trimURL(val) + : val; + // Only trim value if the directionality doesn't change to RTL. + return this.window.windowUtils.getDirectionFromText(trimmedValue) == + this.window.windowUtils.DIRECTION_RTL + ? val + : trimmedValue; + } + + /** + * If appropriate, this prefixes a search string with 'www.' and suffixes it + * with browser.fixup.alternate.suffix prior to navigating. + * + * @param {Event} event + * The event that triggered this query. + * @param {string} value + * The search string that should be canonized. + * @returns {string} + * Returns the canonized URL if available and null otherwise. + */ + _maybeCanonizeURL(event, value) { + // Only add the suffix when the URL bar value isn't already "URL-like", + // and only if we get a keyboard event, to match user expectations. + if ( + !KeyboardEvent.isInstance(event) || + event._disableCanonization || + !event.ctrlKey || + !lazy.UrlbarPrefs.get("ctrlCanonizesURLs") || + !/^\s*[^.:\/\s]+(?:\/.*|\s*)$/i.test(value) + ) { + return null; + } + + let suffix = Services.prefs.getCharPref("browser.fixup.alternate.suffix"); + if (!suffix.endsWith("/")) { + suffix += "/"; + } + + // trim leading/trailing spaces (bug 233205) + value = value.trim(); + + // Tack www. and suffix on. If user has appended directories, insert + // suffix before them (bug 279035). Be careful not to get two slashes. + let firstSlash = value.indexOf("/"); + if (firstSlash >= 0) { + value = + value.substring(0, firstSlash) + + suffix + + value.substring(firstSlash + 1); + } else { + value = value + suffix; + } + + try { + const info = Services.uriFixup.getFixupURIInfo( + value, + Ci.nsIURIFixup.FIXUP_FLAG_FORCE_ALTERNATE_URI + ); + value = info.fixedURI.spec; + } catch (ex) { + console.error(`An error occured while trying to fixup "${value}": ${ex}`); + } + + this.value = value; + return value; + } + + /** + * Autofills a value into the input. The value will be autofilled regardless + * of the input's current value. + * + * @param {object} options + * The options object. + * @param {string} options.value + * The value to autofill. + * @param {integer} options.selectionStart + * The new selectionStart. + * @param {integer} options.selectionEnd + * The new selectionEnd. + * @param {"origin" | "url" | "adaptive"} options.type + * The autofill type, one of: "origin", "url", "adaptive" + * @param {string} options.adaptiveHistoryInput + * If the autofill type is "adaptive", this is the matching `input` value + * from adaptive history. + */ + _autofillValue({ + value, + selectionStart, + selectionEnd, + type, + adaptiveHistoryInput, + }) { + // The autofilled value may be a URL that includes a scheme at the + // beginning. Do not allow it to be trimmed. + this._setValue(value, false); + this.inputField.setSelectionRange(selectionStart, selectionEnd); + this._autofillPlaceholder = { value, type, adaptiveHistoryInput }; + } + + /** + * Loads the url in the appropriate place. + * + * @param {string} url + * The URL to open. + * @param {Event} event + * The event that triggered to load the url. + * @param {string} openUILinkWhere + * Where we expect the result to be opened. + * @param {object} params + * The parameters related to how and where the result will be opened. + * Further supported paramters are listed in utilityOverlay.js#openUILinkIn. + * @param {object} params.triggeringPrincipal + * The principal that the action was triggered from. + * @param {nsIInputStream} [params.postData] + * The POST data associated with a search submission. + * @param {boolean} [params.allowInheritPrincipal] + * Whether the principal can be inherited. + * @param {object} [resultDetails] + * Details of the selected result, if any. + * @param {UrlbarUtils.RESULT_TYPE} [resultDetails.type] + * Details of the result type, if any. + * @param {string} [resultDetails.searchTerm] + * Search term of the result source, if any. + * @param {UrlbarUtils.RESULT_SOURCE} [resultDetails.source] + * Details of the result source, if any. + * @param {object} browser [optional] the browser to use for the load. + */ + _loadURL( + url, + event, + openUILinkWhere, + params, + resultDetails = null, + browser = this.window.gBrowser.selectedBrowser + ) { + // No point in setting these because we'll handleRevert() a few rows below. + if (openUILinkWhere == "current") { + this.value = + lazy.UrlbarPrefs.get("showSearchTermsFeatureGate") && + lazy.UrlbarPrefs.get("showSearchTerms.enabled") && + resultDetails?.searchTerm + ? resultDetails.searchTerm + : url; + browser.userTypedValue = this.value; + } + + // No point in setting this if we are loading in a new window. + if ( + openUILinkWhere != "window" && + this.window.gInitialPages.includes(url) + ) { + browser.initialPageLoadedFromUserAction = url; + } + + try { + lazy.UrlbarUtils.addToUrlbarHistory(url, this.window); + } catch (ex) { + // Things may go wrong when adding url to session history, + // but don't let that interfere with the loading of the url. + console.error(ex); + } + + // TODO: When bug 1498553 is resolved, we should be able to + // remove the !triggeringPrincipal condition here. + if ( + !params.triggeringPrincipal || + params.triggeringPrincipal.isSystemPrincipal + ) { + // Reset DOS mitigations for the basic auth prompt. + delete browser.authPromptAbuseCounter; + + // Reset temporary permissions on the current tab if the user reloads + // the tab via the urlbar. + if ( + openUILinkWhere == "current" && + browser.currentURI && + url === browser.currentURI.spec + ) { + this.window.SitePermissions.clearTemporaryBlockPermissions(browser); + } + } + + params.allowThirdPartyFixup = true; + + if (openUILinkWhere == "current") { + params.targetBrowser = browser; + params.indicateErrorPageLoad = true; + params.allowPinnedTabHostChange = true; + params.allowPopups = url.startsWith("javascript:"); + } else { + params.initiatingDoc = this.window.document; + } + + if ( + this._keyDownEnterDeferred && + event?.keyCode === KeyEvent.DOM_VK_RETURN && + openUILinkWhere === "current" + ) { + // In this case, we move the focus to the browser that loads the content + // upon key up the enter key. + // To do it, send avoidBrowserFocus flag to openTrustedLinkIn() to avoid + // focusing on the browser in the function. And also, set loadedContent + // flag that whether the content is loaded in the current tab by this enter + // key. _keyDownEnterDeferred promise is processed at key up the enter, + // focus on the browser passed by _keyDownEnterDeferred.resolve(). + params.avoidBrowserFocus = true; + this._keyDownEnterDeferred.loadedContent = true; + this._keyDownEnterDeferred.resolve(browser); + } + + // Ensure the window gets the `private` feature if the current window + // is private, unless the caller explicitly requested not to. + if (this.isPrivate && !("private" in params)) { + params.private = true; + } + + // Focus the content area before triggering loads, since if the load + // occurs in a new tab, we want focus to be restored to the content + // area when the current tab is re-selected. + if (!params.avoidBrowserFocus) { + browser.focus(); + // Make sure the domain name stays visible for spoof protection and usability. + this.inputField.setSelectionRange(0, 0); + } + + if (openUILinkWhere != "current") { + this.handleRevert(); + } + + // Notify about the start of navigation. + this._notifyStartNavigation(resultDetails); + + try { + this.window.openTrustedLinkIn(url, openUILinkWhere, params); + } catch (ex) { + // This load can throw an exception in certain cases, which means + // we'll want to replace the URL with the loaded URL: + if (ex.result != Cr.NS_ERROR_LOAD_SHOWED_ERRORPAGE) { + this.handleRevert(); + } + } + + // If we show the focus border after closing the view, it would appear to + // flash since this._on_blur would remove it immediately after. + this.view.close({ showFocusBorder: false }); + } + + /** + * Determines where a URL/page should be opened. + * + * @param {Event} event the event triggering the opening. + * @returns {"current" | "tabshifted" | "tab" | "save" | "window"} + */ + _whereToOpen(event) { + let isKeyboardEvent = KeyboardEvent.isInstance(event); + let reuseEmpty = isKeyboardEvent; + let where = undefined; + if ( + isKeyboardEvent && + (event.altKey || event.getModifierState("AltGraph")) + ) { + // We support using 'alt' to open in a tab, because ctrl/shift + // might be used for canonizing URLs: + where = event.shiftKey ? "tabshifted" : "tab"; + } else if ( + isKeyboardEvent && + event.ctrlKey && + lazy.UrlbarPrefs.get("ctrlCanonizesURLs") + ) { + // If we're allowing canonization, and this is a key event with ctrl + // pressed, open in current tab to allow ctrl-enter to canonize URL. + where = "current"; + } else { + where = this.window.whereToOpenLink(event, false, false); + } + if (lazy.UrlbarPrefs.get("openintab")) { + if (where == "current") { + where = "tab"; + } else if (where == "tab") { + where = "current"; + } + reuseEmpty = true; + } + if ( + where == "tab" && + reuseEmpty && + this.window.gBrowser.selectedTab.isEmpty + ) { + where = "current"; + } + return where; + } + + _initCopyCutController() { + this._copyCutController = new CopyCutController(this); + this.inputField.controllers.insertControllerAt(0, this._copyCutController); + } + + /** + * Searches the context menu for the location of a specific command. + * + * @param {string} menuItemCommand + * The command to search for. + * @returns {string} + * Html element that matches the command or + * the last element if we could not find the command. + */ + #findMenuItemLocation(menuItemCommand) { + let inputBox = this.querySelector("moz-input-box"); + let contextMenu = inputBox.menupopup; + let insertLocation = contextMenu.firstElementChild; + // find the location of the command + while ( + insertLocation.nextElementSibling && + insertLocation.getAttribute("cmd") != menuItemCommand + ) { + insertLocation = insertLocation.nextElementSibling; + } + + return insertLocation; + } + + /** + * Strips known tracking query parameters/ link decorators. + * + * @returns {nsIURI|null} + * The stripped URI or null + */ + #stripURI() { + let copyString = this._getSelectedValueForClipboard(); + if (!copyString) { + return null; + } + let strippedURI = null; + // throws if the selected string is not a valid URI + try { + let uri = Services.io.newURI(copyString); + strippedURI = lazy.QueryStringStripper.stripForCopyOrShare(uri); + } catch (e) { + console.debug(`stripURI: ${e.message}`); + return null; + } + if (strippedURI) { + return this.makeURIReadable(strippedURI); + } + return null; + } + + // The strip-on-share feature will strip known tracking/decorational + // query params from the URI and copy the stripped version to the clipboard. + _initStripOnShare() { + let contextMenu = this.querySelector("moz-input-box").menupopup; + let insertLocation = this.#findMenuItemLocation("cmd_copy"); + if (!insertLocation.getAttribute("cmd") == "cmd_copy") { + return; + } + // set up the menu item + let stripOnShare = this.document.createXULElement("menuitem"); + this.document.l10n.setAttributes( + stripOnShare, + "text-action-strip-on-share" + ); + stripOnShare.setAttribute("anonid", "strip-on-share"); + stripOnShare.id = "strip-on-share"; + + insertLocation.insertAdjacentElement("afterend", stripOnShare); + + // register listener that returns the stripped version of the url + stripOnShare.addEventListener("command", () => { + let strippedURI = this.#stripURI(); + if (!strippedURI) { + // If there is nothing to strip the menu item should not have been visible. + // We might end up here if there was an unexpected URI change. + console.warn("StripOnShare: Unexpected null value."); + return; + } + lazy.ClipboardHelper.copyString(strippedURI.displaySpec); + }); + + // register a listener that hides the menu item if there is nothing to copy or nothing to strip. + contextMenu.addEventListener("popupshowing", () => { + // feature is not enabled + if (!lazy.QUERY_STRIPPING_STRIP_ON_SHARE) { + stripOnShare.setAttribute("hidden", true); + return; + } + let controller = + this.document.commandDispatcher.getControllerForCommand("cmd_copy"); + // url bar is empty + if (!controller.isCommandEnabled("cmd_copy")) { + stripOnShare.setAttribute("hidden", true); + return; + } + // nothing to strip/selection is not a valid url + if (!this.#stripURI()) { + stripOnShare.setAttribute("hidden", true); + return; + } + stripOnShare.setAttribute("hidden", false); + }); + } + + _initPasteAndGo() { + let inputBox = this.querySelector("moz-input-box"); + let contextMenu = inputBox.menupopup; + let insertLocation = this.#findMenuItemLocation("cmd_paste"); + if (!insertLocation) { + return; + } + + let pasteAndGo = this.document.createXULElement("menuitem"); + pasteAndGo.id = "paste-and-go"; + let label = Services.strings + .createBundle("chrome://browser/locale/browser.properties") + .GetStringFromName("pasteAndGo.label"); + pasteAndGo.setAttribute("label", label); + pasteAndGo.setAttribute("anonid", "paste-and-go"); + pasteAndGo.addEventListener("command", () => { + this._suppressStartQuery = true; + + this.select(); + this.window.goDoCommand("cmd_paste"); + this.setResultForCurrentValue(null); + this.controller.clearLastQueryContextCache(); + this.handleCommand(); + + this._suppressStartQuery = false; + }); + + contextMenu.addEventListener("popupshowing", () => { + // Close the results pane when the input field contextual menu is open, + // because paste and go doesn't want a result selection. + this.view.close(); + + let controller = + this.document.commandDispatcher.getControllerForCommand("cmd_paste"); + let enabled = controller.isCommandEnabled("cmd_paste"); + if (enabled) { + pasteAndGo.removeAttribute("disabled"); + } else { + pasteAndGo.setAttribute("disabled", "true"); + } + }); + + insertLocation.insertAdjacentElement("afterend", pasteAndGo); + } + + /** + * This notifies observers that the user has entered or selected something in + * the URL bar which will cause navigation. + * + * We use the observer service, so that we don't need to load extra facilities + * if they aren't being used, e.g. WebNavigation. + * + * @param {UrlbarResult} result + * Details of the result that was selected, if any. + */ + _notifyStartNavigation(result) { + Services.obs.notifyObservers({ result }, "urlbar-user-start-navigation"); + } + + /** + * Returns a search mode object if a result should enter search mode when + * selected. + * + * @param {UrlbarResult} result + * The result to check. + * @param {string} [entry] + * If provided, this will be recorded as the entry point into search mode. + * See setSearchMode() documentation for details. + * @returns {object} A search mode object. Null if search mode should not be + * entered. See setSearchMode documentation for details. + */ + _searchModeForResult(result, entry = null) { + // Search mode is determined by the result's keyword or engine. + if (!result.payload.keyword && !result.payload.engine) { + return null; + } + + let searchMode = lazy.UrlbarUtils.searchModeForToken( + result.payload.keyword + ); + // If result.originalEngine is set, then the user is Alt+Tabbing + // through the one-offs, so the keyword doesn't match the engine. + if ( + !searchMode && + result.payload.engine && + (!result.payload.originalEngine || + result.payload.engine == result.payload.originalEngine) + ) { + searchMode = { engineName: result.payload.engine }; + } + + if (searchMode) { + if (entry) { + searchMode.entry = entry; + } else { + switch (result.providerName) { + case "UrlbarProviderTopSites": + searchMode.entry = "topsites_urlbar"; + break; + case "TabToSearch": + if (result.payload.dynamicType) { + searchMode.entry = "tabtosearch_onboard"; + } else { + searchMode.entry = "tabtosearch"; + } + break; + default: + searchMode.entry = "keywordoffer"; + break; + } + } + } + + return searchMode; + } + + /** + * Updates the UI so that search mode is either entered or exited. + * + * @param {object} searchMode + * See setSearchMode documentation. If null, then search mode is exited. + */ + _updateSearchModeUI(searchMode) { + let { engineName, source, isGeneralPurposeEngine } = searchMode || {}; + + // As an optimization, bail if the given search mode is null but search mode + // is already inactive. Otherwise browser_preferences_usage.js fails due to + // accessing the browser.urlbar.placeholderName pref (via the call to + // BrowserSearch.initPlaceHolder below) too many times. That test does not + // enter search mode, but it triggers many calls to this method with a null + // search mode, via setURI. + if (!engineName && !source && !this.hasAttribute("searchmode")) { + return; + } + + this._searchModeIndicatorTitle.textContent = ""; + this._searchModeLabel.textContent = ""; + this._searchModeIndicatorTitle.removeAttribute("data-l10n-id"); + this._searchModeLabel.removeAttribute("data-l10n-id"); + this.removeAttribute("searchmodesource"); + + if (!engineName && !source) { + try { + // This will throw before DOMContentLoaded in + // PrivateBrowsingUtils.privacyContextFromWindow because + // aWindow.docShell is null. + this.window.BrowserSearch.initPlaceHolder(true); + } catch (ex) {} + this.removeAttribute("searchmode"); + return; + } + + if (engineName) { + // Set text content for the search mode indicator. + this._searchModeIndicatorTitle.textContent = engineName; + this._searchModeLabel.textContent = engineName; + this.document.l10n.setAttributes( + this.inputField, + isGeneralPurposeEngine + ? "urlbar-placeholder-search-mode-web-2" + : "urlbar-placeholder-search-mode-other-engine", + { name: engineName } + ); + } else if (source) { + let sourceName = lazy.UrlbarUtils.getResultSourceName(source); + let l10nID = `urlbar-search-mode-${sourceName}`; + this.document.l10n.setAttributes(this._searchModeIndicatorTitle, l10nID); + this.document.l10n.setAttributes(this._searchModeLabel, l10nID); + this.document.l10n.setAttributes( + this.inputField, + `urlbar-placeholder-search-mode-other-${sourceName}` + ); + this.setAttribute("searchmodesource", sourceName); + } + + this.toggleAttribute("searchmode", true); + // Clear autofill. + if (this._autofillPlaceholder && this.window.gBrowser.userTypedValue) { + this.value = this.window.gBrowser.userTypedValue; + } + // Search mode should only be active when pageproxystate is invalid. + if (this.getAttribute("pageproxystate") == "valid") { + this.value = ""; + this.setPageProxyState("invalid", true); + } + } + + /** + * Determines if we should select all the text in the Urlbar based on the + * Urlbar state, and whether the selection is empty. + */ + _maybeSelectAll() { + if ( + !this._preventClickSelectsAll && + this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING && + this.document.activeElement == this.inputField && + this.inputField.selectionStart == this.inputField.selectionEnd + ) { + this.select(); + } + } + + // Event handlers below. + + _on_command(event) { + // Something is executing a command, likely causing a focus change. This + // should not be recorded as an abandonment. If the user is selecting a + // result menu item or entering search mode from a one-off, then they are + // in the same engagement and we should not discard. + if ( + !event.target.classList.contains("urlbarView-result-menuitem") && + (!event.target.classList.contains("searchbar-engine-one-off-item") || + this.searchMode?.entry != "oneoff") + ) { + this.controller.engagementEvent.discard(); + } + } + + _on_blur(event) { + // We cannot count every blur events after a missed engagement as abandoment + // because the user may have clicked on some view element that executes + // a command causing a focus change. For example opening preferences from + // the oneoff settings button. + // For now we detect that case by discarding the event on command, but we + // may want to figure out a more robust way to detect abandonment. + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + searchSource: this.getSearchSource(event), + }); + + this.focusedViaMousedown = false; + this._handoffSession = undefined; + this._isHandoffSession = false; + this.removeAttribute("focused"); + + if (this._autofillPlaceholder && this.window.gBrowser.userTypedValue) { + // If we were autofilling, remove the autofilled portion, by restoring + // the value to the last typed one. + this.value = this.window.gBrowser.userTypedValue; + } else if (this.value == this._focusUntrimmedValue) { + // If the value was untrimmed by _on_focus and didn't change, trim it. + this.value = this._focusUntrimmedValue; + } + this._focusUntrimmedValue = null; + + this.formatValue(); + this._resetSearchState(); + + // In certain cases, like holding an override key and confirming an entry, + // we don't key a keyup event for the override key, thus we make this + // additional cleanup on blur. + this._clearActionOverride(); + + // The extension input sessions depends more on blur than on the fact we + // actually cancel a running query, so we do it here. + if (lazy.ExtensionSearchHandler.hasActiveInputSession()) { + lazy.ExtensionSearchHandler.handleInputCancelled(); + } + + // Respect the autohide preference for easier inspecting/debugging via + // the browser toolbox. + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + + if (this._revertOnBlurValue == this.value) { + this.handleRevert(); + } + this._revertOnBlurValue = null; + + // If there were search terms shown in the URL bar and the user + // didn't end up modifying the userTypedValue while it was + // focused, change back to a valid pageproxystate. + if ( + this.window.gBrowser.selectedBrowser.searchTerms && + this.window.gBrowser.userTypedValue == null + ) { + this.setPageProxyState("valid", true); + } + + // We may have hidden popup notifications, show them again if necessary. + if ( + this.getAttribute("pageproxystate") != "valid" && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + + // If user move the focus to another component while pressing Enter key, + // then keyup at that component, as we can't get the event, clear the promise. + if (this._keyDownEnterDeferred) { + this._keyDownEnterDeferred.resolve(); + this._keyDownEnterDeferred = null; + } + this._isKeyDownWithCtrl = false; + + Services.obs.notifyObservers(null, "urlbar-blur"); + } + + _on_click(event) { + if ( + event.target == this.inputField || + event.target == this._inputContainer || + event.target.id == SEARCH_BUTTON_ID + ) { + this._maybeSelectAll(); + } + + if (event.target == this._searchModeIndicatorClose && event.button != 2) { + this.searchMode = null; + this.view.oneOffSearchButtons.selectedButton = null; + if (this.view.isOpen) { + this.startQuery({ + event, + }); + } + } + } + + _on_contextmenu(event) { + this.addSearchEngineHelper.refreshContextMenu(event); + + // Context menu opened via keyboard shortcut. + if (!event.button) { + return; + } + + this._maybeSelectAll(); + } + + _on_focus(event) { + if (!this._hideFocus) { + this.setAttribute("focused", "true"); + } + + // When the search term matches the SERP, the URL bar is in a valid + // pageproxystate. In order to only show the search icon, switch to + // an invalid pageproxystate. + if (this.window.gBrowser.selectedBrowser.searchTerms) { + this.setPageProxyState("invalid", true); + } + + // If the value was trimmed, check whether we should untrim it. + // This is necessary when a protocol was typed, but the whole url has + // invalid parts, like the origin, then editing and confirming the trimmed + // value would execute a search instead of visiting the typed url. + if (this.value != this._untrimmedValue) { + let untrim = false; + let fixedURI = this._getURIFixupInfo(this.value)?.preferredURI; + if (fixedURI) { + try { + let expectedURI = Services.io.newURI(this._untrimmedValue); + untrim = fixedURI.displaySpec != expectedURI.displaySpec; + } catch (ex) { + untrim = true; + } + } + if (untrim) { + this.inputField.value = this._focusUntrimmedValue = + this._untrimmedValue; + } + } + + if (this.focusedViaMousedown) { + this.view.autoOpen({ event }); + } else if (this.inputField.hasAttribute("refocused-by-panel")) { + this._maybeSelectAll(); + } + + this._updateUrlTooltip(); + this.formatValue(); + + // Hide popup notifications, to reduce visual noise. + if ( + this.getAttribute("pageproxystate") != "valid" && + this.window.UpdatePopupNotificationsVisibility + ) { + this.window.UpdatePopupNotificationsVisibility(); + } + + Services.obs.notifyObservers(null, "urlbar-focus"); + } + + _on_mouseover(event) { + this._updateUrlTooltip(); + } + + _on_draggableregionleftmousedown(event) { + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + } + + _on_mousedown(event) { + switch (event.currentTarget) { + case this.textbox: + this._mousedownOnUrlbarDescendant = true; + + if ( + event.target != this.inputField && + event.target != this._inputContainer && + event.target.id != SEARCH_BUTTON_ID + ) { + break; + } + + this.focusedViaMousedown = !this.focused; + this._preventClickSelectsAll = this.focused; + + // Keep the focus status, since the attribute may be changed + // upon calling this.focus(). + const hasFocus = this.hasAttribute("focused"); + if (event.target != this.inputField) { + this.focus(); + } + + // The rest of this case only cares about left clicks. + if (event.button != 0) { + break; + } + + // Clear any previous selection unless we are focused, to ensure it + // doesn't affect drag selection. + if (this.focusedViaMousedown) { + this.inputField.setSelectionRange(0, 0); + } + + if (event.target.id == SEARCH_BUTTON_ID) { + this._preventClickSelectsAll = true; + this.search(lazy.UrlbarTokenizer.RESTRICT.SEARCH); + } else { + // Do not suppress the focus border if we are already focused. If we + // did, we'd hide the focus border briefly then show it again if the + // user has Top Sites disabled, creating a flashing effect. + this.view.autoOpen({ + event, + suppressFocusBorder: !hasFocus, + }); + } + break; + case this.window: + if (this._mousedownOnUrlbarDescendant) { + this._mousedownOnUrlbarDescendant = false; + break; + } + // Don't close the view when clicking on a tab; we may want to keep the + // view open on tab switch, and the TabSelect event arrived earlier. + if (event.target.closest("tab")) { + break; + } + + // Close the view when clicking on toolbars and other UI pieces that + // might not automatically remove focus from the input. + // Respect the autohide preference for easier inspecting/debugging via + // the browser toolbox. + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + if (this.view.isOpen && !this.hasAttribute("focused")) { + // In this case, as blur event never happen from the inputField, we + // record abandonment event explicitly. + let blurEvent = new FocusEvent("blur", { + relatedTarget: this.inputField, + }); + this.controller.engagementEvent.record(blurEvent, { + searchString: this._lastSearchString, + searchSource: this.getSearchSource(blurEvent), + }); + } + + this.view.close(); + } + break; + } + } + + _on_input(event) { + if ( + this._autofillPlaceholder && + this.value === this.window.gBrowser.userTypedValue && + (event.inputType === "deleteContentBackward" || + event.inputType === "deleteContentForward") + ) { + // Take a telemetry if user deleted whole autofilled value. + Services.telemetry.scalarAdd("urlbar.autofill_deletion", 1); + } + + let value = this.value; + this.valueIsTyped = true; + this._untrimmedValue = value; + this._resultForCurrentValue = null; + + this.window.gBrowser.userTypedValue = value; + // Unset userSelectionBehavior because the user is modifying the search + // string, thus there's no valid selection. This is also used by the view + // to set "aria-activedescendant", thus it should never get stale. + this.controller.userSelectionBehavior = "none"; + + let compositionState = this._compositionState; + let compositionClosedPopup = this._compositionClosedPopup; + + // Clear composition values if we're no more composing. + if (this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + this._compositionState = lazy.UrlbarUtils.COMPOSITION.NONE; + this._compositionClosedPopup = false; + } + + if (value) { + this.setAttribute("usertyping", "true"); + } else { + this.removeAttribute("usertyping"); + } + this.removeAttribute("actiontype"); + + if ( + this.getAttribute("pageproxystate") == "valid" && + this.value != this._lastValidURLStr + ) { + this.setPageProxyState("invalid", true); + } + + if (!this.view.isOpen) { + this.view.clear(); + } else if (!value && !lazy.UrlbarPrefs.get("suggest.topsites")) { + this.view.clear(); + if (!this.searchMode || !this.view.oneOffSearchButtons.hasView) { + this.view.close(); + return; + } + } + + this.view.removeAccessibleFocus(); + + // During composition with an IME, the following events happen in order: + // 1. a compositionstart event + // 2. some input events + // 3. a compositionend event + // 4. an input event + + // We should do nothing during composition or if composition was canceled + // and we didn't close the popup on composition start. + if ( + !lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition") && + (compositionState == lazy.UrlbarUtils.COMPOSITION.COMPOSING || + (compositionState == lazy.UrlbarUtils.COMPOSITION.CANCELED && + !compositionClosedPopup)) + ) { + return; + } + + // Autofill only when text is inserted (i.e., event.data is not empty) and + // it's not due to pasting. + const allowAutofill = + (!lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition") || + compositionState !== lazy.UrlbarUtils.COMPOSITION.COMPOSING) && + !!event.data && + !lazy.UrlbarUtils.isPasteEvent(event) && + this._maybeAutofillPlaceholder(value); + + this.startQuery({ + searchString: value, + allowAutofill, + resetSearchState: false, + event, + }); + } + + _on_select(event) { + // On certain user input, AutoCopyListener::OnSelectionChange() updates + // the primary selection with user-selected text (when supported). + // Selection::NotifySelectionListeners() then dispatches a "select" event + // under similar conditions via TextInputListener::OnSelectionChange(). + // This event is received here in order to replace the primary selection + // from the editor with text having the adjustments of + // _getSelectedValueForClipboard(), such as adding the scheme for the url. + // + // Other "select" events are also received, however, and must be excluded. + if ( + // _suppressPrimaryAdjustment is set during select(). Don't update + // the primary selection because that is not the intent of user input, + // which may be new tab or urlbar focus. + this._suppressPrimaryAdjustment || + // The check on isHandlingUserInput filters out async "select" events + // from setSelectionRange(), which occur when autofill text is selected. + !this.window.windowUtils.isHandlingUserInput || + !Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ) + ) { + return; + } + + let val = this._getSelectedValueForClipboard(); + if (!val) { + return; + } + + lazy.ClipboardHelper.copyStringToClipboard( + val, + Services.clipboard.kSelectionClipboard + ); + } + + _on_overflow(event) { + const targetIsPlaceholder = + event.originalTarget.implementedPseudoElement == "::placeholder"; + // We only care about the non-placeholder text. + // This shouldn't be needed, see bug 1487036. + if (targetIsPlaceholder) { + return; + } + this._overflowing = true; + this.updateTextOverflow(); + } + + _on_underflow(event) { + const targetIsPlaceholder = + event.originalTarget.implementedPseudoElement == "::placeholder"; + // We only care about the non-placeholder text. + // This shouldn't be needed, see bug 1487036. + if (targetIsPlaceholder) { + return; + } + this._overflowing = false; + + this.updateTextOverflow(); + + this._updateUrlTooltip(); + } + + _on_paste(event) { + let originalPasteData = event.clipboardData.getData("text/plain"); + if (!originalPasteData) { + return; + } + + let oldValue = this.inputField.value; + let oldStart = oldValue.substring(0, this.selectionStart); + // If there is already non-whitespace content in the URL bar + // preceding the pasted content, it's not necessary to check + // protocols used by the pasted content: + if (oldStart.trim()) { + return; + } + let oldEnd = oldValue.substring(this.selectionEnd); + + let fixedURI, keywordAsSent; + try { + ({ fixedURI, keywordAsSent } = Services.uriFixup.getFixupURIInfo( + originalPasteData, + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP + )); + } catch (e) {} + + let pasteData; + if (keywordAsSent) { + // For only keywords, replace any white spaces including line break with + // white space. + pasteData = originalPasteData.replace(/\s/g, " "); + } else if ( + fixedURI?.scheme === "data" && + !fixedURI.spec.match(/^data:.+;base64,/) + ) { + // For data url without base64, replace line break with white space. + pasteData = originalPasteData.replace(/[\r\n]/g, " "); + } else { + // For normal url or data url having basic64, or if fixup failed, just + // remove line breaks. + pasteData = originalPasteData.replace(/[\r\n]/g, ""); + } + + pasteData = lazy.UrlbarUtils.stripUnsafeProtocolOnPaste(pasteData); + + if (originalPasteData != pasteData) { + // Unfortunately we're not allowed to set the bits being pasted + // so cancel this event: + event.preventDefault(); + event.stopImmediatePropagation(); + + const value = oldStart + pasteData + oldEnd; + this.inputField.value = value; + this._untrimmedValue = value; + this.window.gBrowser.userTypedValue = value; + + if (this._untrimmedValue) { + this.setAttribute("usertyping", "true"); + } else { + this.removeAttribute("usertyping"); + } + + // Fix up cursor/selection: + let newCursorPos = oldStart.length + pasteData.length; + this.inputField.setSelectionRange(newCursorPos, newCursorPos); + + this.startQuery({ + searchString: this.inputField.value, + allowAutofill: false, + resetSearchState: false, + event, + }); + } + } + + _on_scrollend(event) { + this.updateTextOverflow(); + } + + _on_TabSelect(event) { + this._gotTabSelect = true; + this._afterTabSelectAndFocusChange(); + } + + _on_beforeinput(event) { + if (event.data && this._keyDownEnterDeferred) { + // Ignore char key input while processing enter key. + event.preventDefault(); + } + } + + _on_keydown(event) { + if (event.keyCode === KeyEvent.DOM_VK_RETURN) { + if (this._keyDownEnterDeferred) { + this._keyDownEnterDeferred.reject(); + } + this._keyDownEnterDeferred = lazy.PromiseUtils.defer(); + event._disableCanonization = this._isKeyDownWithCtrl; + } else if (event.keyCode !== KeyEvent.DOM_VK_CONTROL && event.ctrlKey) { + this._isKeyDownWithCtrl = true; + } + + // Due to event deferring, it's possible preventDefault() won't be invoked + // soon enough to actually prevent some of the default behaviors, thus we + // have to handle the event "twice". This first immediate call passes false + // as second argument so that handleKeyNavigation will only simulate the + // event handling, without actually executing actions. + // TODO (Bug 1541806): improve this handling, maybe by delaying actions + // instead of events. + if (this.eventBufferer.shouldDeferEvent(event)) { + this.controller.handleKeyNavigation(event, false); + } + this._toggleActionOverride(event); + this.eventBufferer.maybeDeferEvent(event, () => { + this.controller.handleKeyNavigation(event); + }); + } + + async _on_keyup(event) { + if (event.keyCode === KeyEvent.DOM_VK_CONTROL) { + this._isKeyDownWithCtrl = false; + } + + this._toggleActionOverride(event); + + // Pressing Enter key while pressing Meta key, and next, even when releasing + // Enter key before releasing Meta key, the keyup event is not fired. + // Therefore, if Enter keydown is detecting, continue the post processing + // for Enter key when any keyup event is detected. + if (this._keyDownEnterDeferred) { + if (this._keyDownEnterDeferred.loadedContent) { + try { + const loadingBrowser = await this._keyDownEnterDeferred.promise; + // Ensure the selected browser didn't change in the meanwhile. + if (this.window.gBrowser.selectedBrowser === loadingBrowser) { + loadingBrowser.focus(); + // Make sure the domain name stays visible for spoof protection and usability. + this.inputField.setSelectionRange(0, 0); + } + } catch (ex) { + // Not all the Enter actions in the urlbar will cause a navigation, then it + // is normal for this to be rejected. + // If _keyDownEnterDeferred was rejected on keydown, we don't nullify it here + // to ensure not overwriting the new value created by keydown. + } + } else { + // Discard the _keyDownEnterDeferred promise to receive any key inputs immediately. + this._keyDownEnterDeferred.resolve(); + } + + this._keyDownEnterDeferred = null; + } + } + + _on_compositionstart(event) { + if (this._compositionState == lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to start a nested composition?"); + } + this._compositionState = lazy.UrlbarUtils.COMPOSITION.COMPOSING; + + if (lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition")) { + return; + } + + // Close the view. This will also stop searching. + if (this.view.isOpen) { + // We're closing the view, but we want to retain search mode if the + // selected result was previewing it. + if (this.searchMode) { + // If we entered search mode with an empty string, clear userTypedValue, + // otherwise confirmSearchMode may try to set it as value. + // This can happen for example if we entered search mode typing a + // a partial engine domain and selecting a tab-to-search result. + if (!this.value) { + this.window.gBrowser.userTypedValue = null; + } + this.confirmSearchMode(); + } + this._compositionClosedPopup = true; + this.view.close(); + } else { + this._compositionClosedPopup = false; + } + } + + _on_compositionend(event) { + if (this._compositionState != lazy.UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to stop a non existing composition?"); + } + + if (!lazy.UrlbarPrefs.get("keepPanelOpenDuringImeComposition")) { + // Clear the selection and the cached result, since they refer to the + // state before this composition. A new input even will be generated + // after this. + this.view.clearSelection(); + this._resultForCurrentValue = null; + } + + // We can't yet retrieve the committed value from the editor, since it isn't + // completely committed yet. We'll handle it at the next input event. + this._compositionState = event.data + ? lazy.UrlbarUtils.COMPOSITION.COMMIT + : lazy.UrlbarUtils.COMPOSITION.CANCELED; + } + + _on_dragstart(event) { + // Drag only if the gesture starts from the input field. + let nodePosition = this.inputField.compareDocumentPosition( + event.originalTarget + ); + if ( + event.target != this.inputField && + !(nodePosition & Node.DOCUMENT_POSITION_CONTAINED_BY) + ) { + return; + } + + // Don't cover potential drop targets on the toolbars or in content. + this.view.close(); + + // Only customize the drag data if the entire value is selected and it's a + // loaded URI. Use default behavior otherwise. + if ( + this.selectionStart != 0 || + this.selectionEnd != this.inputField.textLength || + this.getAttribute("pageproxystate") != "valid" + ) { + return; + } + + let uri = this.makeURIReadable(this.window.gBrowser.currentURI); + let href = uri.displaySpec; + let title = this.window.gBrowser.contentTitle || href; + + event.dataTransfer.setData("text/x-moz-url", `${href}\n${title}`); + event.dataTransfer.setData("text/plain", href); + event.dataTransfer.setData("text/html", `<a href="${href}">${title}</a>`); + event.dataTransfer.effectAllowed = "copyLink"; + event.stopPropagation(); + } + + _on_dragover(event) { + if (!getDroppableData(event)) { + event.dataTransfer.dropEffect = "none"; + } + } + + _on_drop(event) { + let droppedItem = getDroppableData(event); + let droppedURL = URL.isInstance(droppedItem) + ? droppedItem.href + : droppedItem; + if (droppedURL && droppedURL !== this.window.gBrowser.currentURI.spec) { + let principal = Services.droppedLinkHandler.getTriggeringPrincipal(event); + this.value = droppedURL; + this.setPageProxyState("invalid"); + this.focus(); + // To simplify tracking of events, register an initial event for event + // telemetry, to replace the missing input event. + this.controller.engagementEvent.start(event); + this.controller.clearLastQueryContextCache(); + this.handleNavigation({ triggeringPrincipal: principal }); + // For safety reasons, in the drop case we don't want to immediately show + // the the dropped value, instead we want to keep showing the current page + // url until an onLocationChange happens. + // See the handling in `setURI` for further details. + this.window.gBrowser.userTypedValue = null; + this.setURI(null, true); + } + } + + _on_customizationstarting() { + this.blur(); + + this.inputField.controllers.removeController(this._copyCutController); + delete this._copyCutController; + } + + _on_aftercustomization() { + this._initCopyCutController(); + this._initPasteAndGo(); + this._initStripOnShare(); + } +} + +/** + * Tries to extract droppable data from a DND event. + * + * @param {Event} event The DND event to examine. + * @returns {URL|string|null} + * null if there's a security reason for which we should do nothing. + * A URL object if it's a value we can load. + * A string value otherwise. + */ +function getDroppableData(event) { + let links; + try { + links = Services.droppedLinkHandler.dropLinks(event); + } catch (ex) { + // This is either an unexpected failure or a security exception; in either + // case we should always return null. + return null; + } + // The URL bar automatically handles inputs with newline characters, + // so we can get away with treating text/x-moz-url flavours as text/plain. + if (links.length && links[0].url) { + event.preventDefault(); + let href = links[0].url; + if (lazy.UrlbarUtils.stripUnsafeProtocolOnPaste(href) != href) { + // We may have stripped an unsafe protocol like javascript: and if so + // there's no point in handling a partial drop. + event.stopImmediatePropagation(); + return null; + } + + try { + // If this throws, checkLoadURStrWithPrincipal would also throw, + // as that's what it does with things that don't pass the IO + // service's newURI constructor without fixup. It's conceivable we + // may want to relax this check in the future (so e.g. www.foo.com + // gets fixed up), but not right now. + let url = new URL(href); + // If we succeed, try to pass security checks. If this works, return the + // URL object. If the *security checks* fail, return null. + try { + let principal = + Services.droppedLinkHandler.getTriggeringPrincipal(event); + Services.scriptSecurityManager.checkLoadURIStrWithPrincipal( + principal, + url.href, + Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL + ); + return url; + } catch (ex) { + return null; + } + } catch (ex) { + // We couldn't make a URL out of this. Continue on, and return text below. + } + } + // Handle as text. + return event.dataTransfer.getData("text/plain"); +} + +/** + * Decodes the given URI for displaying it in the address bar without losing + * information, such that hitting Enter again will load the same URI. + * + * @param {nsIURI} aURI + * The URI to decode + * @returns {string} + * The decoded URI + */ +function losslessDecodeURI(aURI) { + let scheme = aURI.scheme; + let value = aURI.displaySpec; + + // Try to decode as UTF-8 if there's no encoding sequence that we would break. + if (!/%25(?:3B|2F|3F|3A|40|26|3D|2B|24|2C|23)/i.test(value)) { + let decodeASCIIOnly = !["https", "http", "file", "ftp"].includes(scheme); + if (decodeASCIIOnly) { + // This only decodes ascii characters (hex) 20-7e, except 25 (%). + // This avoids both cases stipulated below (%-related issues, and \r, \n + // and \t, which would be %0d, %0a and %09, respectively) as well as any + // non-US-ascii characters. + value = value.replace( + /%(2[0-4]|2[6-9a-f]|[3-6][0-9a-f]|7[0-9a-e])/g, + decodeURI + ); + } else { + try { + value = decodeURI(value) + // decodeURI decodes %25 to %, which creates unintended encoding + // sequences. Re-encode it, unless it's part of a sequence that + // survived decodeURI, i.e. one for: + // ';', '/', '?', ':', '@', '&', '=', '+', '$', ',', '#' + // (RFC 3987 section 3.2) + .replace( + /%(?!3B|2F|3F|3A|40|26|3D|2B|24|2C|23)/gi, + encodeURIComponent + ); + } catch (e) {} + } + } + + // Encode potentially invisible characters: + // U+0000-001F: C0/C1 control characters + // U+007F-009F: commands + // U+00A0, U+1680, U+2000-200A, U+202F, U+205F, U+3000: other spaces + // U+2028-2029: line and paragraph separators + // U+2800: braille empty pattern + // U+FFFC: object replacement character + // Encode any trailing whitespace that may be part of a pasted URL, so that it + // doesn't get eaten away by the location bar (bug 410726). + // Encode all adjacent space chars (U+0020), to prevent spoofing attempts + // where they would push part of the URL to overflow the location bar + // (bug 1395508). A single space, or the last space if the are many, is + // preserved to maintain readability of certain urls. We only do this for the + // common space, because others may be eaten when copied to the clipboard, so + // it's safer to preserve them encoded. + value = value.replace( + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u2800\u3000\ufffc]|[\r\n\t]|\u0020(?=\u0020)|\s$/g, + encodeURIComponent + ); + + // Encode characters that are ignorable, can't be rendered usefully, or may + // confuse users. + // + // Default ignorable characters; ZWNJ (U+200C) and ZWJ (U+200D) are excluded + // per bug 582186: + // U+00AD, U+034F, U+06DD, U+070F, U+115F-1160, U+17B4, U+17B5, U+180B-180E, + // U+2060, U+FEFF, U+200B, U+2060-206F, U+3164, U+FE00-FE0F, U+FFA0, + // U+FFF0-FFFB, U+1D173-1D17A (U+D834 + DD73-DD7A), + // U+E0000-E0FFF (U+DB40-DB43 + U+DC00-DFFF) + // Bidi control characters (RFC 3987 sections 3.2 and 4.1 paragraph 6): + // U+061C, U+200E, U+200F, U+202A-202E, U+2066-2069 + // Other format characters in the Cf category that are unlikely to be rendered + // usefully: + // U+0600-0605, U+08E2, U+110BD (U+D804 + U+DCBD), + // U+110CD (U+D804 + U+DCCD), U+13430-13438 (U+D80D + U+DC30-DC38), + // U+1BCA0-1BCA3 (U+D82F + U+DCA0-DCA3) + // Mimicking UI parts: + // U+1F50F-1F513 (U+D83D + U+DD0F-DD13), U+1F6E1 (U+D83D + U+DEE1) + value = value.replace( + // eslint-disable-next-line no-misleading-character-class + /[\u00ad\u034f\u061c\u06dd\u070f\u115f\u1160\u17b4\u17b5\u180b-\u180e\u200b\u200e\u200f\u202a-\u202e\u2060-\u206f\u3164\u0600-\u0605\u08e2\ufe00-\ufe0f\ufeff\uffa0\ufff0-\ufffb]|\ud804[\udcbd\udccd]|\ud80d[\udc30-\udc38]|\ud82f[\udca0-\udca3]|\ud834[\udd73-\udd7a]|[\udb40-\udb43][\udc00-\udfff]|\ud83d[\udd0f-\udd13\udee1]/g, + encodeURIComponent + ); + return value; +} + +/** + * Handles copy and cut commands for the urlbar. + */ +class CopyCutController { + /** + * @param {UrlbarInput} urlbar + * The UrlbarInput instance to use this controller for. + */ + constructor(urlbar) { + this.urlbar = urlbar; + } + + /** + * @param {string} command + * The name of the command to handle. + */ + doCommand(command) { + let urlbar = this.urlbar; + let val = urlbar._getSelectedValueForClipboard(); + if (!val) { + return; + } + + if (command == "cmd_cut" && this.isCommandEnabled(command)) { + let start = urlbar.selectionStart; + let end = urlbar.selectionEnd; + urlbar.inputField.value = + urlbar.inputField.value.substring(0, start) + + urlbar.inputField.value.substring(end); + urlbar.inputField.setSelectionRange(start, start); + + let event = new UIEvent("input", { + bubbles: true, + cancelable: false, + view: urlbar.window, + detail: 0, + }); + urlbar.inputField.dispatchEvent(event); + } + + lazy.ClipboardHelper.copyString(val); + } + + /** + * @param {string} command + * The name of the command to check. + * @returns {boolean} + * Whether the command is handled by this controller. + */ + supportsCommand(command) { + switch (command) { + case "cmd_copy": + case "cmd_cut": + return true; + } + return false; + } + + /** + * @param {string} command + * The name of the command to check. + * @returns {boolean} + * Whether the command should be enabled. + */ + isCommandEnabled(command) { + return ( + this.supportsCommand(command) && + (command != "cmd_cut" || !this.urlbar.readOnly) && + this.urlbar.selectionStart < this.urlbar.selectionEnd + ); + } + + onEvent() {} +} + +/** + * Manages the Add Search Engine contextual menu entries. + * + * Note: setEnginesFromBrowser must be invoked from the outside when the + * page provided engines list changes. + * refreshContextMenu must be invoked when the context menu is opened. + */ +class AddSearchEngineHelper { + /** + * @type {UrlbarSearchOneOffs} + */ + shortcutButtons; + + /** + * @param {UrlbarInput} input The parent UrlbarInput. + */ + constructor(input) { + this.input = input; + this.shortcutButtons = input.view.oneOffSearchButtons; + } + + /** + * If there's more than this number of engines, the context menu offers + * them in a submenu. + * + * @returns {number} + */ + get maxInlineEngines() { + return this.shortcutButtons._maxInlineAddEngines; + } + + /** + * Invoked by browser when the list of available engines changes. + * + * @param {object} browser The invoking browser. + */ + setEnginesFromBrowser(browser) { + this.browsingContext = browser.browsingContext; + // Make a copy of the array for state comparison. + let engines = browser.engines?.slice() || []; + if (!this._sameEngines(this.engines, engines)) { + this.engines = engines; + this.shortcutButtons.updateWebEngines(engines); + } + } + + _sameEngines(engines1, engines2) { + if (engines1?.length != engines2?.length) { + return false; + } + return lazy.ObjectUtils.deepEqual( + engines1.map(e => e.title), + engines2.map(e => e.title) + ); + } + + _createMenuitem(engine, index) { + let elt = this.input.document.createXULElement("menuitem"); + elt.setAttribute("anonid", `add-engine-${index}`); + elt.classList.add("menuitem-iconic"); + elt.classList.add("context-menu-add-engine"); + elt.setAttribute("data-l10n-id", "search-one-offs-add-engine"); + elt.setAttribute( + "data-l10n-args", + JSON.stringify({ engineName: engine.title }) + ); + elt.setAttribute("uri", engine.uri); + if (engine.icon) { + elt.setAttribute("image", engine.icon); + } else { + elt.removeAttribute("image", engine.icon); + } + elt.addEventListener("command", this._onCommand.bind(this)); + return elt; + } + + _createMenu(engine) { + let elt = this.input.document.createXULElement("menu"); + elt.setAttribute("anonid", "add-engine-menu"); + elt.classList.add("menu-iconic"); + elt.classList.add("context-menu-add-engine"); + elt.setAttribute("data-l10n-id", "search-one-offs-add-engine-menu"); + if (engine.icon) { + elt.setAttribute("image", engine.icon); + } + let popup = this.input.document.createXULElement("menupopup"); + elt.appendChild(popup); + return elt; + } + + refreshContextMenu() { + let engines = this.engines; + + // Certain operations, like customization, destroy and recreate widgets, + // so we cannot rely on cached elements. + if (!this.input.querySelector(".menuseparator-add-engine")) { + this.contextSeparator = + this.input.document.createXULElement("menuseparator"); + this.contextSeparator.setAttribute("anonid", "add-engine-separator"); + this.contextSeparator.classList.add("menuseparator-add-engine"); + this.contextSeparator.collapsed = true; + let contextMenu = this.input.querySelector("moz-input-box").menupopup; + contextMenu.appendChild(this.contextSeparator); + } + + this.contextSeparator.collapsed = !engines.length; + let curElt = this.contextSeparator; + // Remove the previous items, if any. + for (let elt = curElt.nextElementSibling; elt; ) { + let nextElementSibling = elt.nextElementSibling; + elt.remove(); + elt = nextElementSibling; + } + + // If the page provides too many engines, we only show a single menu entry + // with engines in a submenu. + if (engines.length > this.maxInlineEngines) { + // Set the menu button's image to the image of the first engine. The + // offered engines may have differing images, so there's no perfect + // choice here. + let elt = this._createMenu(engines[0]); + this.contextSeparator.insertAdjacentElement("afterend", elt); + curElt = elt.lastElementChild; + } + + // Insert the engines, either in the contextual menu or the sub menu. + for (let i = 0; i < engines.length; ++i) { + let elt = this._createMenuitem(engines[i], i); + if (curElt.localName == "menupopup") { + curElt.appendChild(elt); + } else { + curElt.insertAdjacentElement("afterend", elt); + } + curElt = elt; + } + } + + async _onCommand(event) { + let added = await lazy.SearchUIUtils.addOpenSearchEngine( + event.target.getAttribute("uri"), + event.target.getAttribute("image"), + this.browsingContext + ).catch(console.error); + if (added) { + // Remove the offered engine from the list. The browser updated the + // engines list at this point, so we just have to refresh the menu.) + this.refreshContextMenu(); + } + } +} diff --git a/browser/components/urlbar/UrlbarMuxerUnifiedComplete.sys.mjs b/browser/components/urlbar/UrlbarMuxerUnifiedComplete.sys.mjs new file mode 100644 index 0000000000..04ef02d97b --- /dev/null +++ b/browser/components/urlbar/UrlbarMuxerUnifiedComplete.sys.mjs @@ -0,0 +1,1327 @@ +/* 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/. */ + +/** + * This module exports a component used to sort results in a UrlbarQueryContext. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarMuxer, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + UrlbarUtils.getLogger({ prefix: "MuxerUnifiedComplete" }) +); + +/** + * Class used to create a muxer. + * The muxer receives and sorts results in a UrlbarQueryContext. + */ +class MuxerUnifiedComplete extends UrlbarMuxer { + constructor() { + super(); + } + + get name() { + return "UnifiedComplete"; + } + + /** + * Sorts results in the given UrlbarQueryContext. + * + * @param {UrlbarQueryContext} context + * The query context. + * @param {Array} unsortedResults + * The array of UrlbarResult that is not sorted yet. + */ + sort(context, unsortedResults) { + // This method is called multiple times per keystroke, so it should be as + // fast and efficient as possible. We do two passes through the results: + // one to collect state for the second pass, and then a second to build the + // sorted list of results. If you find yourself writing something like + // context.results.find(), filter(), sort(), etc., modify one or both passes + // instead. + + // Global state we'll use to make decisions during this sort. + let state = { + context, + // RESULT_GROUP => array of results belonging to the group, excluding + // group-relative suggestedIndex results + resultsByGroup: new Map(), + // RESULT_GROUP => array of group-relative suggestedIndex results + // belonging to the group + suggestedIndexResultsByGroup: new Map(), + // This is analogous to `maxResults` except it's the total available + // result span instead of the total available result count. We'll add + // results until `usedResultSpan` would exceed `availableResultSpan`. + availableResultSpan: context.maxResults, + // The total span of results that have been added so far. + usedResultSpan: 0, + strippedUrlToTopPrefixAndTitle: new Map(), + urlToTabResultType: new Map(), + addedRemoteTabUrls: new Set(), + addedSwitchTabUrls: new Set(), + canShowPrivateSearch: unsortedResults.length > 1, + canShowTailSuggestions: true, + // Form history and remote suggestions added so far. Used for deduping + // suggestions. Also includes the heuristic query string if the heuristic + // is a search result. All strings in the set are lowercased. + suggestions: new Set(), + canAddTabToSearch: true, + hasUnitConversionResult: false, + maxHeuristicResultSpan: 0, + maxTabToSearchResultSpan: 0, + // When you add state, update _copyState() as necessary. + }; + + // Do the first pass over all results to build some state. + for (let result of unsortedResults) { + // Add each result to the appropriate `resultsByGroup` map. + let group = UrlbarUtils.getResultGroup(result); + let resultsByGroup = + result.hasSuggestedIndex && result.isSuggestedIndexRelativeToGroup + ? state.suggestedIndexResultsByGroup + : state.resultsByGroup; + let results = resultsByGroup.get(group); + if (!results) { + results = []; + resultsByGroup.set(group, results); + } + results.push(result); + + // Update pre-add state. + this._updateStatePreAdd(result, state); + } + + // Now that the first pass is done, adjust the available result span. + if (state.maxTabToSearchResultSpan) { + // Subtract the max tab-to-search span. + state.availableResultSpan = Math.max( + state.availableResultSpan - state.maxTabToSearchResultSpan, + 0 + ); + } + if (state.maxHeuristicResultSpan) { + if (lazy.UrlbarPrefs.get("experimental.hideHeuristic")) { + // The heuristic is hidden. The muxer will include it but the view will + // hide it. Increase the available span to compensate so that the total + // visible span accurately reflects `context.maxResults`. + state.availableResultSpan += state.maxHeuristicResultSpan; + } else if (context.maxResults > 0) { + // `context.maxResults` is positive. Make sure there's room for the + // heuristic even if it means exceeding `context.maxResults`. + state.availableResultSpan = Math.max( + state.availableResultSpan, + state.maxHeuristicResultSpan + ); + } + } + + // Determine the result groups to use for this sort. In search mode with + // an engine, show search suggestions first. + let rootGroup = context.searchMode?.engineName + ? lazy.UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }) + : lazy.UrlbarPrefs.resultGroups; + lazy.logger.debug(`Groups: ${JSON.stringify(rootGroup)}`); + + // Fill the root group. + let [sortedResults] = this._fillGroup( + rootGroup, + { availableSpan: state.availableResultSpan, maxResultCount: Infinity }, + state + ); + + // Add global suggestedIndex results unless the max result count is zero, + // which isn't really supported but it's easy to honor here. We add them all + // even if they exceed the max because we assume they're high-priority + // results that should always be shown, and as long as the max is positive + // it's not a problem to exceed it sometimes. In practice that will happen + // only for small, non-default values of `maxRichResults`. + if (context.maxResults > 0) { + let suggestedIndexResults = state.resultsByGroup.get( + UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX + ); + if (suggestedIndexResults) { + this._addSuggestedIndexResults( + suggestedIndexResults, + sortedResults, + state + ); + } + } + + context.results = sortedResults; + } + + /** + * Returns a *deep* copy of state (except for `state.context`, which is + * shallow copied). i.e., any Maps, Sets, and arrays in the state should be + * recursively copied so that the original state is not modified when the copy + * is modified. + * + * @param {object} state + * The muxer state to copy. + * @returns {object} + * A deep copy of the state. + */ + _copyState(state) { + let copy = Object.assign({}, state, { + resultsByGroup: new Map(), + suggestedIndexResultsByGroup: new Map(), + strippedUrlToTopPrefixAndTitle: new Map( + state.strippedUrlToTopPrefixAndTitle + ), + urlToTabResultType: new Map(state.urlToTabResultType), + addedRemoteTabUrls: new Set(state.addedRemoteTabUrls), + addedSwitchTabUrls: new Set(state.addedSwitchTabUrls), + suggestions: new Set(state.suggestions), + }); + + // Deep copy the `resultsByGroup` maps. + for (let key of ["resultsByGroup", "suggestedIndexResultsByGroup"]) { + for (let [group, results] of state[key]) { + copy[key].set(group, [...results]); + } + } + + return copy; + } + + /** + * Recursively fills a result group and its children. + * + * There are two ways to limit the number of results in a group: + * + * (1) By max total result span using the `availableSpan` property. The group + * will be filled so that the total span of its results does not exceed this + * value. + * + * (2) By max total result count using the `maxResultCount` property. The + * group will be filled so that the total number of its results does not + * exceed this value. + * + * Both `availableSpan` and `maxResultCount` may be defined, and the group's + * results will be capped to whichever limit is reached first. If either is + * not defined, then the group inherits that limit from its parent group. + * + * In addition to limiting their total number of results, groups can also + * control the composition of their child groups by using flex ratios. A group + * can define a `flexChildren: true` property, and in that case each of its + * children should have a `flex` property. Each child will be filled according + * to the ratio of its flex value and the sum of the flex values of all the + * children, similar to HTML flexbox. If some children do not fill up but + * others do, the filled-up children will be allowed to grow to use up the + * unfilled space. + * + * @param {object} group + * The result group to fill. + * @param {object} limits + * An object with optional `availableSpan` and `maxResultCount` properties + * as described above. They will be used as the limits for the group. + * @param {object} state + * The muxer state. + * @returns {Array} + * `[results, usedLimits, hasMoreResults]` -- see `_addResults`. + */ + _fillGroup(group, limits, state) { + // Get the group's suggestedIndex results. Reminder: `group.group` is a + // `RESULT_GROUP` constant. + let suggestedIndexResults; + if ("group" in group) { + suggestedIndexResults = state.suggestedIndexResultsByGroup.get( + group.group + ); + if (suggestedIndexResults) { + // Subtract them from the group's limits so there will be room for them + // later. Create a new `limits` object so we don't modify the caller's. + let [span, resultCount] = suggestedIndexResults.reduce( + ([sum, count], result) => { + const spanSize = UrlbarUtils.getSpanForResult(result); + sum += spanSize; + if (spanSize) { + count++; + } + return [sum, count]; + }, + [0, 0] + ); + limits = { ...limits }; + limits.availableSpan = Math.max(limits.availableSpan - span, 0); + limits.maxResultCount = Math.max( + limits.maxResultCount - resultCount, + 0 + ); + } + } + + // Fill the group. If it has children, fill them recursively. Otherwise fill + // the group directly. + let [results, usedLimits, hasMoreResults] = group.children + ? this._fillGroupChildren(group, limits, state) + : this._addResults(group.group, limits, state); + + // Add the group's suggestedIndex results. + if (suggestedIndexResults) { + let suggestedIndexUsedLimits = this._addSuggestedIndexResults( + suggestedIndexResults, + results, + state + ); + for (let [key, value] of Object.entries(suggestedIndexUsedLimits)) { + usedLimits[key] += value; + } + } + + return [results, usedLimits, hasMoreResults]; + } + + /** + * Helper for `_fillGroup` that fills a group's children. + * + * @param {object} group + * The result group to fill. It's assumed to have a `children` property. + * @param {object} limits + * An object with optional `availableSpan` and `maxResultCount` properties + * as described in `_fillGroup`. + * @param {object} state + * The muxer state. + * @param {Array} flexDataArray + * See `_updateFlexData`. + * @returns {Array} + * `[results, usedLimits, hasMoreResults]` -- see `_addResults`. + */ + _fillGroupChildren(group, limits, state, flexDataArray = null) { + // If the group has flexed children, update the data we use during flex + // calculations. + // + // Handling flex is complicated so we discuss it briefly. We may do multiple + // passes for a group with flexed children in order to try to optimally fill + // them. If after one pass some children do not fill up but others do, we'll + // do another pass that tries to overfill the filled-up children while still + // respecting their flex ratios. We'll continue to do passes until all + // children stop filling up or we reach the parent's limits. The way we + // overfill children is by increasing their individual limits to make up for + // the unused space in their underfilled siblings. Before starting a new + // pass, we discard the results from the current pass so the new pass starts + // with a clean slate. That means we need to copy the global sort state + // (`state`) before modifying it in the current pass so we can use its + // original value in the next pass [1]. + // + // [1] Instead of starting each pass with a clean slate in this way, we + // could accumulate results with each pass since we only ever add results to + // flexed children and never remove them. However, that would subvert muxer + // logic related to the global state (deduping, `_canAddResult`) since we + // generally assume the muxer adds results in the order they appear. + let stateCopy; + if (group.flexChildren) { + stateCopy = this._copyState(state); + flexDataArray = this._updateFlexData(group, limits, flexDataArray); + } + + // Fill each child group, collecting all results in the `results` array. + let results = []; + let usedLimits = {}; + for (let key of Object.keys(limits)) { + usedLimits[key] = 0; + } + let anyChildUnderfilled = false; + let anyChildHasMoreResults = false; + for (let i = 0; i < group.children.length; i++) { + let child = group.children[i]; + let flexData = flexDataArray?.[i]; + + // Compute the child's limits. + let childLimits = {}; + for (let key of Object.keys(limits)) { + childLimits[key] = flexData + ? flexData.limits[key] + : Math.min( + typeof child[key] == "number" ? child[key] : Infinity, + limits[key] - usedLimits[key] + ); + } + + // Recurse and fill the child. + let [childResults, childUsedLimits, childHasMoreResults] = + this._fillGroup(child, childLimits, state); + results = results.concat(childResults); + for (let key of Object.keys(usedLimits)) { + usedLimits[key] += childUsedLimits[key]; + } + anyChildHasMoreResults = anyChildHasMoreResults || childHasMoreResults; + + if (flexData?.hasMoreResults) { + // The child is flexed and we possibly added more results to it. + flexData.usedLimits = childUsedLimits; + flexData.hasMoreResults = childHasMoreResults; + anyChildUnderfilled = + anyChildUnderfilled || + (!childHasMoreResults && + [...Object.entries(childLimits)].every( + ([key, limit]) => flexData.usedLimits[key] < limit + )); + } + } + + // If the children are flexed and some underfilled but others still have + // more results, do another pass. + if (anyChildUnderfilled && anyChildHasMoreResults) { + [results, usedLimits, anyChildHasMoreResults] = this._fillGroupChildren( + group, + limits, + stateCopy, + flexDataArray + ); + + // Update `state` in place so that it's also updated in the caller. + for (let [key, value] of Object.entries(stateCopy)) { + state[key] = value; + } + } + + return [results, usedLimits, anyChildHasMoreResults]; + } + + /** + * Updates flex-related state used while filling a group. + * + * @param {object} group + * The result group being filled. + * @param {object} limits + * An object defining the group's limits as described in `_fillGroup`. + * @param {Array} flexDataArray + * An array parallel to `group.children`. The object at index i corresponds + * to the child in `group.children` at index i. Each object maintains some + * flex-related state for its child and is updated during each pass in + * `_fillGroup` for `group`. When this method is called in the first pass, + * this argument should be null, and the method will create and return a new + * `flexDataArray` array that should be used in the remainder of the first + * pass and all subsequent passes. + * @returns {Array} + * A new `flexDataArray` when called in the first pass, and `flexDataArray` + * itself when called in subsequent passes. + */ + _updateFlexData(group, limits, flexDataArray) { + flexDataArray = + flexDataArray || + group.children.map((child, index) => { + let data = { + // The index of the corresponding child in `group.children`. + index, + // The child's limits. + limits: {}, + // The fractional parts of the child's unrounded limits; see below. + limitFractions: {}, + // The used-up portions of the child's limits. + usedLimits: {}, + // True if `state.resultsByGroup` has more results of the child's + // `RESULT_GROUP`. This is not related to the child's limits. + hasMoreResults: true, + // The child's flex value. + flex: typeof child.flex == "number" ? child.flex : 0, + }; + for (let key of Object.keys(limits)) { + data.limits[key] = 0; + data.limitFractions[key] = 0; + data.usedLimits[key] = 0; + } + return data; + }); + + // The data objects for children with more results (i.e., that are still + // fillable). + let fillableDataArray = []; + + // The sum of the flex values of children with more results. + let fillableFlexSum = 0; + + for (let data of flexDataArray) { + if (data.hasMoreResults) { + fillableFlexSum += data.flex; + fillableDataArray.push(data); + } + } + + // Update each limit. + for (let [key, limit] of Object.entries(limits)) { + // Calculate the group's limit only including children with more results. + let fillableLimit = limit; + for (let data of flexDataArray) { + if (!data.hasMoreResults) { + fillableLimit -= data.usedLimits[key]; + } + } + + // Allow for the possibility that some children may have gone over limit. + // `fillableLimit` will be negative in that case. + fillableLimit = Math.max(fillableLimit, 0); + + // Next we'll compute the limits of children with more results. This value + // is the sum of those limits. It may differ from `fillableLimit` due to + // the fact that each individual child limit must be an integer. + let summedFillableLimit = 0; + + // Compute the limits of children with more results. If there are also + // children that don't have more results, then these new limits will be + // larger than they were in the previous pass. + for (let data of fillableDataArray) { + let unroundedLimit = fillableLimit * (data.flex / fillableFlexSum); + // `limitFraction` is the fractional part of the unrounded ideal limit. + // e.g., for 5.234 it will be 0.234. We use this to minimize the + // mathematical error when tweaking limits below. + data.limitFractions[key] = unroundedLimit - Math.floor(unroundedLimit); + data.limits[key] = Math.round(unroundedLimit); + summedFillableLimit += data.limits[key]; + } + + // As mentioned above, the sum of the individual child limits may not + // equal the group's fillable limit. If the sum is smaller, the group will + // end up with too few results. If it's larger, the group will have the + // correct number of results (since we stop adding results once limits are + // reached) but it may end up with a composition that does not reflect the + // child flex ratios as accurately as possible. + // + // In either case, tweak the individual limits so that (1) their sum + // equals the group's fillable limit, and (2) the composition respects the + // flex ratios with as little mathematical error as possible. + if (summedFillableLimit != fillableLimit) { + // Collect the flex datas with a non-zero limit fractions. We'll round + // them up or down depending on whether the sum is larger or smaller + // than the group's fillable limit. + let fractionalDataArray = fillableDataArray.filter( + data => data.limitFractions[key] + ); + + let diff; + if (summedFillableLimit < fillableLimit) { + // The sum is smaller. We'll increment individual limits until the sum + // is equal, starting with the child whose limit fraction is closest + // to 1 in order to minimize error. + diff = 1; + fractionalDataArray.sort((a, b) => { + // Sort by fraction descending so larger fractions are first. + let cmp = b.limitFractions[key] - a.limitFractions[key]; + // Secondarily sort by index ascending so that children with the + // same fraction are incremented in the order they appear, allowing + // earlier children to have larger spans. + return cmp || a.index - b.index; + }); + } else if (fillableLimit < summedFillableLimit) { + // The sum is larger. We'll decrement individual limits until the sum + // is equal, starting with the child whose limit fraction is closest + // to 0 in order to minimize error. + diff = -1; + fractionalDataArray.sort((a, b) => { + // Sort by fraction ascending so smaller fractions are first. + let cmp = a.limitFractions[key] - b.limitFractions[key]; + // Secondarily sort by index descending so that children with the + // same fraction are decremented in reverse order, allowing earlier + // children to retain larger spans. + return cmp || b.index - a.index; + }); + } + + // Now increment or decrement individual limits until their sum is equal + // to the group's fillable limit. + while (summedFillableLimit != fillableLimit) { + if (!fractionalDataArray.length) { + // This shouldn't happen, but don't let it break us. + lazy.logger.error("fractionalDataArray is empty!"); + break; + } + let data = flexDataArray[fractionalDataArray.shift().index]; + data.limits[key] += diff; + summedFillableLimit += diff; + } + } + } + + return flexDataArray; + } + + /** + * Adds results to a group using the results from its `RESULT_GROUP` in + * `state.resultsByGroup`. + * + * @param {UrlbarUtils.RESULT_GROUP} groupConst + * The group's `RESULT_GROUP`. + * @param {object} limits + * An object defining the group's limits as described in `_fillGroup`. + * @param {object} state + * Global state that we use to make decisions during this sort. + * @returns {Array} + * `[results, usedLimits, hasMoreResults]` where: + * results: A flat array of results in the group, empty if no results + * were added. + * usedLimits: An object defining the amount of each limit that the + * results use. For each possible limit property (see `_fillGroup`), + * there will be a corresponding property in this object. For example, + * if 3 results are added with a total span of 4, then this object will + * be: { maxResultCount: 3, availableSpan: 4 } + * hasMoreResults: True if `state.resultsByGroup` has more results of + * the same `RESULT_GROUP`. This is not related to the group's limits. + */ + _addResults(groupConst, limits, state) { + let usedLimits = {}; + for (let key of Object.keys(limits)) { + usedLimits[key] = 0; + } + + // For form history, maxHistoricalSearchSuggestions == 0 implies the user + // has opted out of form history completely, so we override the max result + // count here in that case. Other values of maxHistoricalSearchSuggestions + // are ignored and we use the flex defined on the form history group. + if ( + groupConst == UrlbarUtils.RESULT_GROUP.FORM_HISTORY && + !lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") + ) { + // Create a new `limits` object so we don't modify the caller's. + limits = { ...limits }; + limits.maxResultCount = 0; + } + + let addedResults = []; + let groupResults = state.resultsByGroup.get(groupConst); + while ( + groupResults?.length && + state.usedResultSpan < state.availableResultSpan && + [...Object.entries(limits)].every(([k, limit]) => usedLimits[k] < limit) + ) { + let result = groupResults[0]; + if (this._canAddResult(result, state)) { + let span = UrlbarUtils.getSpanForResult(result); + let newUsedSpan = usedLimits.availableSpan + span; + if (limits.availableSpan < newUsedSpan) { + // Adding the result would exceed the group's available span, so stop + // adding results to it. Skip the shift() below so the result can be + // added to later groups. + break; + } + + addedResults.push(result); + usedLimits.availableSpan = newUsedSpan; + if (span) { + usedLimits.maxResultCount++; + } + state.usedResultSpan += span; + this._updateStatePostAdd(result, state); + } + + // We either add or discard results in the order they appear in + // `groupResults`, so shift() them off. That way later groups with the + // same `RESULT_GROUP` won't include results that earlier groups have + // added or discarded. + groupResults.shift(); + } + + return [addedResults, usedLimits, !!groupResults?.length]; + } + + /** + * Returns whether a result can be added to its group given the current sort + * state. + * + * @param {UrlbarResult} result + * The result. + * @param {object} state + * Global state that we use to make decisions during this sort. + * @returns {boolean} + * True if the result can be added and false if it should be discarded. + */ + // TODO (Bug 1741273): Refactor this method to avoid an eslint complexity + // error or increase the complexity threshold. + // eslint-disable-next-line complexity + _canAddResult(result, state) { + // QuickSuggest results are shown unless a weather result is also present + // or they are navigational suggestions that duplicate the heuristic. + if (result.providerName == lazy.UrlbarProviderQuickSuggest.name) { + if (state.weatherResult) { + return false; + } + + let heuristicUrl = state.context.heuristicResult?.payload.url; + if ( + heuristicUrl && + result.payload.telemetryType == "top_picks" && + !lazy.UrlbarPrefs.get("experimental.hideHeuristic") + ) { + let opts = { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimSlash: true, + }; + result.payload.dupedHeuristic = + UrlbarUtils.stripPrefixAndTrim(heuristicUrl, opts)[0] == + UrlbarUtils.stripPrefixAndTrim(result.payload.url, opts)[0]; + return !result.payload.dupedHeuristic; + } + return true; + } + + // We expect UrlbarProviderPlaces sent us the highest-ranked www. and non-www + // origins, if any. Now, compare them to each other and to the heuristic + // result. + // + // 1. If the heuristic result is lower ranked than both, discard the www + // origin, unless it has a different page title than the non-www + // origin. This is a guard against deduping when www.site.com and + // site.com have different content. + // 2. If the heuristic result is higher than either the www origin or + // non-www origin: + // 2a. If the heuristic is a www origin, discard the non-www origin. + // 2b. If the heuristic is a non-www origin, discard the www origin. + if ( + !result.heuristic && + result.type == UrlbarUtils.RESULT_TYPE.URL && + result.payload.url + ) { + let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim( + result.payload.url, + { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + } + ); + let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl); + // If the condition below is not met, we are deduping a result against + // itself. + if ( + topPrefixData && + (prefix != topPrefixData.prefix || + result.providerName != topPrefixData.providerName) + ) { + let prefixRank = UrlbarUtils.getPrefixRank(prefix); + if ( + (prefixRank < topPrefixData.rank && + (prefix.endsWith("www.") == topPrefixData.prefix.endsWith("www.") || + result.payload?.title == topPrefixData.title)) || + (prefix == topPrefixData.prefix && + result.providerName != topPrefixData.providerName) + ) { + return false; + } + } + } + + // Discard results that dupe autofill. + if ( + state.context.heuristicResult && + state.context.heuristicResult.autofill && + !result.autofill && + state.context.heuristicResult.payload?.url == result.payload.url && + state.context.heuristicResult.type == result.type && + !lazy.UrlbarPrefs.get("experimental.hideHeuristic") + ) { + return false; + } + + // HeuristicFallback may add non-heuristic results in some cases, but those + // should be retained only if the heuristic result comes from it. + if ( + !result.heuristic && + result.providerName == "HeuristicFallback" && + state.context.heuristicResult?.providerName != "HeuristicFallback" + ) { + return false; + } + + if (result.providerName == lazy.UrlbarProviderTabToSearch.name) { + // Discard the result if a tab-to-search result was added already. + if (!state.canAddTabToSearch) { + return false; + } + + // In cases where the heuristic result is not a URL and we have a + // tab-to-search result, the tab-to-search provider determined that the + // typed string is similar to an engine domain. We can let the + // tab-to-search result through. + if (state.context.heuristicResult?.type == UrlbarUtils.RESULT_TYPE.URL) { + // Discard the result if the heuristic result is not autofill and we are + // not making an exception for a fuzzy match. + if ( + !state.context.heuristicResult.autofill && + !result.payload.satisfiesAutofillThreshold + ) { + return false; + } + + let autofillHostname = new URL( + state.context.heuristicResult.payload.url + ).hostname; + let [autofillDomain] = UrlbarUtils.stripPrefixAndTrim( + autofillHostname, + { + stripWww: true, + } + ); + // Strip the public suffix because we want to allow matching "domain.it" + // with "domain.com". + autofillDomain = UrlbarUtils.stripPublicSuffixFromHost(autofillDomain); + if (!autofillDomain) { + return false; + } + + // For tab-to-search results, result.payload.url is the engine's domain + // with the public suffix already stripped, for example "www.mozilla.". + let [engineDomain] = UrlbarUtils.stripPrefixAndTrim( + result.payload.url, + { + stripWww: true, + } + ); + // Discard if the engine domain does not end with the autofilled one. + if (!engineDomain.endsWith(autofillDomain)) { + return false; + } + } + } + + // Discard "Search in a Private Window" if appropriate. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.inPrivateWindow && + !state.canShowPrivateSearch + ) { + return false; + } + + // Discard form history and remote suggestions that dupe previously added + // suggestions or the heuristic. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.lowerCaseSuggestion + ) { + let suggestion = result.payload.lowerCaseSuggestion.trim(); + if (!suggestion || state.suggestions.has(suggestion)) { + return false; + } + } + + // Discard tail suggestions if appropriate. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.tail && + !result.payload.isRichSuggestion && + !state.canShowTailSuggestions + ) { + return false; + } + + // Discard remote tab results that dupes another remote tab or a + // switch-to-tab result. + if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { + if (state.addedRemoteTabUrls.has(result.payload.url)) { + return false; + } + let maybeDupeType = state.urlToTabResultType.get(result.payload.url); + if (maybeDupeType == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { + return false; + } + } + + // Discard switch-to-tab results that dupes another switch-to-tab result. + if ( + result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && + state.addedSwitchTabUrls.has(result.payload.url) + ) { + return false; + } + + // Discard history results that dupe either remote or switch-to-tab results. + if ( + !result.heuristic && + result.type == UrlbarUtils.RESULT_TYPE.URL && + result.payload.url && + state.urlToTabResultType.has(result.payload.url) + ) { + return false; + } + + // Discard SERPs from browser history that dupe either the heuristic or + // previously added suggestions. + if ( + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && + result.type == UrlbarUtils.RESULT_TYPE.URL + ) { + let submission = Services.search.parseSubmissionURL(result.payload.url); + if (submission) { + let resultQuery = submission.terms.trim().toLocaleLowerCase(); + if (state.suggestions.has(resultQuery)) { + // If the result's URL is the same as a brand new SERP URL created + // from the query string modulo certain URL params, then treat the + // result as a dupe and discard it. + let [newSerpURL] = UrlbarUtils.getSearchQueryUrl( + submission.engine, + submission.terms + ); + if ( + lazy.UrlbarSearchUtils.serpsAreEquivalent( + result.payload.url, + newSerpURL + ) + ) { + return false; + } + } + } + } + + // When in an engine search mode, discard URL results whose hostnames don't + // include the root domain of the search mode engine. + if (state.context.searchMode?.engineName && result.payload.url) { + let engine = Services.search.getEngineByName( + state.context.searchMode.engineName + ); + if (engine) { + let searchModeRootDomain = + lazy.UrlbarSearchUtils.getRootDomainFromEngine(engine); + let resultUrl = new URL(result.payload.url); + // Add a trailing "." to increase the stringency of the check. This + // check covers most general cases. Some edge cases are not covered, + // like `resultUrl` being ebay.mydomain.com, which would escape this + // check if `searchModeRootDomain` was "ebay". + if (!resultUrl.hostname.includes(`${searchModeRootDomain}.`)) { + return false; + } + } + } + + // Discard history results that dupe the quick suggest result. + if ( + state.quickSuggestResult && + !result.heuristic && + result.type == UrlbarUtils.RESULT_TYPE.URL && + lazy.QuickSuggest.isURLEquivalentToResultURL( + result.payload.url, + state.quickSuggestResult + ) + ) { + return false; + } + + // Discard history results whose URLs were originally sponsored. We use the + // presence of a partner's URL search param to detect these. The param is + // defined in the pref below, which is also used for the newtab page. + if ( + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && + result.type == UrlbarUtils.RESULT_TYPE.URL + ) { + let param = Services.prefs.getCharPref( + "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam" + ); + if (param) { + let [key, value] = param.split("="); + let searchParams; + try { + ({ searchParams } = new URL(result.payload.url)); + } catch (error) {} + if ( + (value === undefined && searchParams?.has(key)) || + (value !== undefined && searchParams?.getAll(key).includes(value)) + ) { + return false; + } + } + } + + // Heuristic results must always be the first result. If this result is a + // heuristic but we've already added results, discard it. Normally this + // should never happen because the standard result groups are set up so + // that there's always at most one heuristic and it's always first, but + // since result groups are stored in a pref and can therefore be modified + // by the user, we perform this check. + if (result.heuristic && state.usedResultSpan) { + return false; + } + + // Google search engine might suggest a result for unit conversion with + // format that starts with "= ". If our UnitConversion can provide the + // result, we discard the suggestion of Google in order to deduplicate. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.engine == "Google" && + result.payload.suggestion?.startsWith("= ") && + state.hasUnitConversionResult + ) { + return false; + } + + // Include the result. + return true; + } + + /** + * Updates the global state that we use to make decisions during sort. This + * should be called for results before we've decided whether to add or discard + * them. + * + * @param {UrlbarResult} result + * The result. + * @param {object} state + * Global state that we use to make decisions during this sort. + */ + _updateStatePreAdd(result, state) { + // check if this result should trigger an exposure + // if so mark the result properties and skip the rest of the state setting. + if (this._checkAndSetExposureProperties(result)) { + return; + } + + // Keep track of the largest heuristic result span. + if (result.heuristic && this._canAddResult(result, state)) { + state.maxHeuristicResultSpan = Math.max( + state.maxHeuristicResultSpan, + UrlbarUtils.getSpanForResult(result) + ); + } + + // Subtract from `availableResultSpan` the span of global suggestedIndex + // results so there will be room for them at the end of the sort. Except + // when `maxRichResults` is zero and other special cases, we assume + // suggestedIndex results will always be shown regardless of the total + // available result span, `context.maxResults`, and `maxRichResults`. + if ( + result.hasSuggestedIndex && + !result.isSuggestedIndexRelativeToGroup && + this._canAddResult(result, state) + ) { + let span = UrlbarUtils.getSpanForResult(result); + if (result.providerName == lazy.UrlbarProviderTabToSearch.name) { + state.maxTabToSearchResultSpan = Math.max( + state.maxTabToSearchResultSpan, + span + ); + } else { + state.availableResultSpan = Math.max( + state.availableResultSpan - span, + 0 + ); + } + } + + // Save some state we'll use later to dedupe URL results. + if ( + (result.type == UrlbarUtils.RESULT_TYPE.URL || + result.type == UrlbarUtils.RESULT_TYPE.KEYWORD) && + result.payload.url && + (!result.heuristic || !lazy.UrlbarPrefs.get("experimental.hideHeuristic")) + ) { + let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim( + result.payload.url, + { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + } + ); + let prefixRank = UrlbarUtils.getPrefixRank(prefix); + let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl); + let topPrefixRank = topPrefixData ? topPrefixData.rank : -1; + if ( + topPrefixRank < prefixRank || + // If a quick suggest result has the same stripped URL and prefix rank + // as another result, store the quick suggest as the top rank so we + // discard the other during deduping. That happens after the user picks + // the quick suggest: The URL is added to history and later both a + // history result and the quick suggest may match a query. + (topPrefixRank == prefixRank && + result.providerName == lazy.UrlbarProviderQuickSuggest.name) + ) { + // strippedUrl => { prefix, title, rank, providerName } + state.strippedUrlToTopPrefixAndTitle.set(strippedUrl, { + prefix, + title: result.payload.title, + rank: prefixRank, + providerName: result.providerName, + }); + } + } + + // Save some state we'll use later to dedupe results from open/remote tabs. + if ( + result.payload.url && + (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH || + (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB && + !state.urlToTabResultType.has(result.payload.url))) + ) { + // url => result type + state.urlToTabResultType.set(result.payload.url, result.type); + } + + // If we find results other than the heuristic, "Search in Private + // Window," or tail suggestions, then we should hide tail suggestions + // since they're a last resort. + if ( + state.canShowTailSuggestions && + !result.heuristic && + (result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + (!result.payload.inPrivateWindow && !result.payload.tail)) + ) { + state.canShowTailSuggestions = false; + } + + if (result.providerName == lazy.UrlbarProviderQuickSuggest.name) { + state.quickSuggestResult = result; + } + + if (result.providerName == lazy.UrlbarProviderWeather.name) { + state.weatherResult = result; + } + + state.hasUnitConversionResult = + state.hasUnitConversionResult || result.providerName == "UnitConversion"; + } + + /** + * Updates the global state that we use to make decisions during sort. This + * should be called for results after they've been added. It should not be + * called for discarded results. + * + * @param {UrlbarResult} result + * The result. + * @param {object} state + * Global state that we use to make decisions during this sort. + */ + _updateStatePostAdd(result, state) { + // bail early if the result will be hidden from the final view. + if (result.exposureResultHidden) { + return; + } + + // Update heuristic state. + if (result.heuristic) { + state.context.heuristicResult = result; + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.query && + !lazy.UrlbarPrefs.get("experimental.hideHeuristic") + ) { + let query = result.payload.query.trim().toLocaleLowerCase(); + if (query) { + state.suggestions.add(query); + } + } + } + + // The "Search in a Private Window" result should only be shown when there + // are other results and all of them are searches. It should not be shown + // if the user typed an alias because that's an explicit engine choice. + if ( + !Services.search.separatePrivateDefaultUrlbarResultEnabled || + (state.canShowPrivateSearch && + (result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.payload.providesSearchMode || + (result.heuristic && result.payload.keyword))) + ) { + state.canShowPrivateSearch = false; + } + + // Update suggestions. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.lowerCaseSuggestion + ) { + let suggestion = result.payload.lowerCaseSuggestion.trim(); + if (suggestion) { + state.suggestions.add(suggestion); + } + } + + // Avoid multiple tab-to-search results. + // TODO (Bug 1670185): figure out better strategies to manage this case. + if (result.providerName == lazy.UrlbarProviderTabToSearch.name) { + state.canAddTabToSearch = false; + // We want to record in urlbar.tips once per engagement per engine. Since + // whether these results are shown is dependent on the Muxer, we must + // add to `enginesShown` here. + if (result.payload.dynamicType) { + lazy.UrlbarProviderTabToSearch.enginesShown.onboarding.add( + result.payload.engine + ); + } else { + lazy.UrlbarProviderTabToSearch.enginesShown.regular.add( + result.payload.engine + ); + } + } + + // Sync will send us duplicate remote tabs if multiple copies of a tab are + // open on a synced client. Keep track of which remote tabs we've added to + // dedupe these. + if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { + state.addedRemoteTabUrls.add(result.payload.url); + } + + // Keep track of which switch tabs we've added to dedupe switch tabs. + if (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { + state.addedSwitchTabUrls.add(result.payload.url); + } + } + + /** + * Inserts results with suggested indexes. This can be called for either + * global or group-relative suggestedIndex results. It should be called after + * `sortedResults` has been filled in. + * + * @param {Array} suggestedIndexResults + * Results with a `suggestedIndex` property. + * @param {Array} sortedResults + * The sorted results. For global suggestedIndex results, this should be the + * final list of all results before suggestedIndex results are inserted. For + * group-relative suggestedIndex results, this should be the final list of + * results in the group before group-relative suggestedIndex results are + * inserted. + * @param {object} state + * Global state that we use to make decisions during this sort. + * @returns {object} + * A `usedLimits` object that describes the total span and count of all the + * added results. See `_addResults`. + */ + _addSuggestedIndexResults(suggestedIndexResults, sortedResults, state) { + let usedLimits = { + availableSpan: 0, + maxResultCount: 0, + }; + + if (!suggestedIndexResults?.length) { + // This is just a slight optimization; no need to continue. + return usedLimits; + } + + // Partition the results into positive- and negative-index arrays. Positive + // indexes are relative to the start of the list and negative indexes are + // relative to the end. + let positive = []; + let negative = []; + for (let result of suggestedIndexResults) { + let results = result.suggestedIndex < 0 ? negative : positive; + results.push(result); + } + + // Sort the positive results ascending so that results at the end of the + // array don't end up offset by later insertions at the front. + positive.sort((a, b) => { + if (a.suggestedIndex !== b.suggestedIndex) { + return a.suggestedIndex - b.suggestedIndex; + } + + if (a.providerName === b.providerName) { + return 0; + } + + // If same suggestedIndex, change the displaying order along to following + // provider priority. + // TabToSearch > QuickSuggest > Other providers + if (a.providerName === lazy.UrlbarProviderTabToSearch.name) { + return 1; + } + if (b.providerName === lazy.UrlbarProviderTabToSearch.name) { + return -1; + } + if (a.providerName === lazy.UrlbarProviderQuickSuggest.name) { + return 1; + } + if (b.providerName === lazy.UrlbarProviderQuickSuggest.name) { + return -1; + } + + return 0; + }); + + // Conversely, sort the negative results descending so that results at the + // front of the array don't end up offset by later insertions at the end. + negative.sort((a, b) => b.suggestedIndex - a.suggestedIndex); + + // Insert the results. We start with the positive results because we have + // tests that assume they're inserted first. In practice it shouldn't matter + // because there's no good reason we would ever have a negative result come + // before a positive result in the same query. Even if we did, we have to + // insert one before the other, and there's no right or wrong order. + for (let results of [positive, negative]) { + let prevResult; + let prevIndex; + for (let result of results) { + if (this._canAddResult(result, state)) { + let index; + if ( + prevResult && + prevResult.suggestedIndex == result.suggestedIndex + ) { + index = prevIndex; + } else { + index = + result.suggestedIndex >= 0 + ? Math.min(result.suggestedIndex, sortedResults.length) + : Math.max(result.suggestedIndex + sortedResults.length + 1, 0); + } + prevResult = result; + prevIndex = index; + sortedResults.splice(index, 0, result); + + // Adjust the limits based on span size. + const resultSpan = UrlbarUtils.getSpanForResult(result); + usedLimits.availableSpan += resultSpan; + if (resultSpan) { + usedLimits.maxResultCount++; + } + this._updateStatePostAdd(result, state); + } + } + } + + return usedLimits; + } + + /** + * Checks exposure eligibility and visibility for the given result. + * If the result passes the exposure check, we set two properties + * on the UrlbarResult: `result.exposureResultType` a string containing + * the results of `UrlbarUtils.searchEngagementTelemetryType` and + * `result.exposureResultHidden` a boolean which indicates whether the + * result should be hidden from the view. + * + * + * @param {UrlbarResult} result + * The result. + * @returns {boolean} + * A boolean indicating if this is a hidden exposure result. + */ + _checkAndSetExposureProperties(result) { + const exposureResultsPref = lazy.UrlbarPrefs.get("exposureResults"); + const exposureResults = exposureResultsPref?.split(","); + if (exposureResults) { + const telemetryType = UrlbarUtils.searchEngagementTelemetryType(result); + if (exposureResults.includes(telemetryType)) { + result.exposureResultType = telemetryType; + result.exposureResultHidden = !lazy.UrlbarPrefs.get( + "showExposureResults" + ); + } + } + + return result.exposureResultHidden; + } +} + +export var UrlbarMuxerUnifiedComplete = new MuxerUnifiedComplete(); diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs new file mode 100644 index 0000000000..a8addd7e79 --- /dev/null +++ b/browser/components/urlbar/UrlbarPrefs.sys.mjs @@ -0,0 +1,1601 @@ +/* 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/. */ + +/** + * This module exports the UrlbarPrefs singleton, which manages preferences for + * the urlbar. It also provides access to urlbar Nimbus variables as if they are + * preferences, but only for variables with fallback prefs. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const PREF_URLBAR_BRANCH = "browser.urlbar."; + +// Prefs are defined as [pref name, default value] or [pref name, [default +// value, type]]. In the former case, the getter method name is inferred from +// the typeof the default value. +// +// NOTE: Don't name prefs (relative to the `browser.urlbar` branch) the same as +// Nimbus urlbar features. Doing so would cause a name collision because pref +// names and Nimbus feature names are both kept as keys in UrlbarPref's map. For +// a list of Nimbus features, see toolkit/components/nimbus/FeatureManifest.yaml. +const PREF_URLBAR_DEFAULTS = new Map([ + // Whether we announce to screen readers when tab-to-search results are + // inserted. + ["accessibility.tabToSearch.announceResults", true], + + // Feature gate pref for addon suggestions in the urlbar. + ["addons.featureGate", false], + + // The number of times the user has clicked the "Show less frequently" command + // for addon suggestions. + ["addons.showLessFrequentlyCount", 0], + + // "Autofill" is the name of the feature that automatically completes domains + // and URLs that the user has visited as the user is typing them in the urlbar + // textbox. If false, autofill will be disabled. + ["autoFill", true], + + // Whether enabling adaptive history autofill. This pref is a fallback for the + // Nimbus variable `autoFillAdaptiveHistoryEnabled`. + ["autoFill.adaptiveHistory.enabled", false], + + // Minimum char length of the user's search string to enable adaptive history + // autofill. This pref is a fallback for the Nimbus variable + // `autoFillAdaptiveHistoryMinCharsThreshold`. + ["autoFill.adaptiveHistory.minCharsThreshold", 0], + + // Threshold for use count of input history that we handle as adaptive history + // autofill. If the use count is this value or more, it will be a candidate. + // Set the threshold to not be candidate the input history passed approximately + // 30 days since user input it as the default. + ["autoFill.adaptiveHistory.useCountThreshold", [0.47, "float"]], + + // If true, the domains of the user's installed search engines will be + // autofilled even if the user hasn't actually visited them. + ["autoFill.searchEngines", false], + + // Affects the frecency threshold of the autofill algorithm. The threshold is + // the mean of all origin frecencies plus one standard deviation multiplied by + // this value. See UrlbarProviderPlaces. + ["autoFill.stddevMultiplier", [0.0, "float"]], + + // Whether best match results can be blocked. This pref is a fallback for the + // Nimbus variable `bestMatchBlockingEnabled`. + ["bestMatch.blockingEnabled", true], + + // Whether the best match feature is enabled. + ["bestMatch.enabled", true], + + // Whether to show a link for using the search functionality provided by the + // active view if the the view utilizes OpenSearch. + ["contextualSearch.enabled", false], + + // Whether using `ctrl` when hitting return/enter in the URL bar + // (or clicking 'go') should prefix 'www.' and suffix + // browser.fixup.alternate.suffix to the URL bar value prior to + // navigating. + ["ctrlCanonizesURLs", true], + + // Whether copying the entire URL from the location bar will put a human + // readable (percent-decoded) URL on the clipboard. + ["decodeURLsOnCopy", false], + + // The amount of time (ms) to wait after the user has stopped typing before + // fetching results. However, we ignore this for the very first result (the + // "heuristic" result). We fetch it as fast as possible. + ["delay", 50], + + // Some performance tests disable this because extending the urlbar needs + // layout information that we can't get before the first paint. (Or we could + // but this would mean flushing layout.) + ["disableExtendForTests", false], + + // Ensure we use trailing dots for DNS lookups for single words that could + // be hosts. + ["dnsResolveFullyQualifiedNames", true], + + // Controls when to DNS resolve single word search strings, after they were + // searched for. If the string is resolved as a valid host, show a + // "Did you mean to go to 'host'" prompt. + // 0 - never resolve; 1 - use heuristics (default); 2 - always resolve + ["dnsResolveSingleWordsAfterSearch", 0], + + // Whether telemetry events should be recorded. + ["eventTelemetry.enabled", false], + + // Whether we expand the font size when when the urlbar is + // focused. + ["experimental.expandTextOnFocus", false], + + // Whether the heuristic result is hidden. + ["experimental.hideHeuristic", false], + + // Whether the urlbar displays a permanent search button. + ["experimental.searchButton", false], + + // Comma-separated list of `source.providers` combinations, that are used to + // determine if an exposure event should be fired. This can be set by a + // Nimbus variable and is expected to be set via nimbus experiment + // configuration. + ["exposureResults", ""], + + // When we send events to (privileged) extensions (urlbar API), we wait this + // amount of time in milliseconds for them to respond before timing out. + ["extension.timeout", 400], + + // When we send events to extensions that use the omnibox API, we wait this + // amount of time in milliseconds for them to respond before timing out. + ["extension.omnibox.timeout", 3000], + + // When true, `javascript:` URLs are not included in search results. + ["filter.javascript", true], + + // Applies URL highlighting and other styling to the text in the urlbar input. + ["formatting.enabled", true], + + // Interval time until taking pause impression telemetry. + ["searchEngagementTelemetry.pauseImpressionIntervalMs", 1000], + + // Boolean to determine if the providers defined in `exposureResults` + // should be displayed in search results. This can be set by a + // Nimbus variable and is expected to be set via nimbus experiment + // configuration. For the control branch of an experiment this would be + // false and true for the treatment. + ["showExposureResults", false], + + // Whether Firefox Suggest group labels are shown in the urlbar view in en-* + // locales. Labels are not shown in other locales but likely will be in the + // future. + ["groupLabels.enabled", true], + + // Whether the results panel should be kept open during IME composition. + ["keepPanelOpenDuringImeComposition", false], + + // As a user privacy measure, don't fetch results from remote services for + // searches that start by pasting a string longer than this. The pref name + // indicates search suggestions, but this is used for all remote results. + ["maxCharsForSearchSuggestions", 100], + + // The maximum number of form history results to include. + ["maxHistoricalSearchSuggestions", 0], + + // The maximum number of results in the urlbar popup. + ["maxRichResults", 10], + + // Comma-separated list of client variants to send to Merino + ["merino.clientVariants", ""], + + // Whether Merino is enabled as a quick suggest source. + ["merino.enabled", false], + + // The Merino endpoint URL, not including parameters. + ["merino.endpointURL", "https://merino.services.mozilla.com/api/v1/suggest"], + + // Comma-separated list of providers to request from Merino + ["merino.providers", ""], + + // Timeout for Merino fetches (ms). + ["merino.timeoutMs", 200], + + // Whether addresses and search results typed into the address bar + // should be opened in new tabs by default. + ["openintab", false], + + // When true, URLs in the user's history that look like search result pages + // are styled to look like search engine results instead of the usual history + // results. + ["restyleSearches", false], + + // If true, we show tail suggestions when available. + ["richSuggestions.tail", true], + + // Hidden pref. Disables checks that prevent search tips being shown, thus + // showing them every time the newtab page or the default search engine + // homepage is opened. + ["searchTips.test.ignoreShowLimits", false], + + // Whether to show each local search shortcut button in the view. + ["shortcuts.bookmarks", true], + ["shortcuts.tabs", true], + ["shortcuts.history", true], + ["shortcuts.quickactions", false], + + // Whether to show search suggestions before general results. + ["showSearchSuggestionsFirst", true], + + // Global toggle for whether the show search terms feature + // can be used at all, and enabled/disabled by the user. + ["showSearchTerms.featureGate", false], + + // If true, show the search term in the Urlbar while on + // a default search engine results page. + ["showSearchTerms.enabled", true], + + // Whether speculative connections should be enabled. + ["speculativeConnect.enabled", true], + + // When `bestMatch.enabled` is true, this controls whether results will + // include best matches. + ["suggest.bestmatch", true], + + // Whether results will include the user's bookmarks. + ["suggest.bookmark", true], + + // Whether results will include a calculator. + ["suggest.calculator", false], + + // Whether results will include search engines (e.g. tab-to-search). + ["suggest.engines", true], + + // Whether results will include the user's history. + ["suggest.history", true], + + // Whether results will include switch-to-tab results. + ["suggest.openpage", true], + + // Whether results will include synced tab results. The syncing of open tabs + // must also be enabled, from Sync preferences. + ["suggest.remotetab", true], + + // Whether results will include QuickActions in the default search mode. + ["suggest.quickactions", false], + + // If disabled, QuickActions will not be included in either the default search + // mode or the QuickActions search mode. + ["quickactions.enabled", false], + + // Whether we show the Actions section in about:preferences. + ["quickactions.showPrefs", false], + + // Whether we will match QuickActions within a phrase and not only a prefix. + ["quickactions.matchInPhrase", true], + + // Show multiple actions in a random order. + ["quickactions.randomOrderActions", false], + + // The minumum amount of characters required for the user to input before + // matching actions. Setting this to 0 will show the actions in the + // zero prefix state. + ["quickactions.minimumSearchString", 3], + + // Whether results will include non-sponsored quick suggest suggestions. + ["suggest.quicksuggest.nonsponsored", false], + + // Whether results will include sponsored quick suggest suggestions. + ["suggest.quicksuggest.sponsored", false], + + // If `browser.urlbar.addons.featureGate` is true, this controls whether + // addon suggestions are turned on. + ["suggest.addons", true], + + // Whether results will include search suggestions. + ["suggest.searches", false], + + // Whether results will include top sites and the view will open on focus. + ["suggest.topsites", true], + + // If `browser.urlbar.weather.featureGate` is true, this controls whether + // weather suggestions are turned on. + ["suggest.weather", true], + + // JSON'ed array of blocked quick suggest URL digests. + ["quicksuggest.blockedDigests", ""], + + // Whether the usual non-best-match quick suggest results can be blocked. This + // pref is a fallback for the Nimbus variable `quickSuggestBlockingEnabled`. + ["quicksuggest.blockingEnabled", true], + + // Global toggle for whether the quick suggest feature is enabled, i.e., + // sponsored and recommended results related to the user's search string. + ["quicksuggest.enabled", false], + + // Whether non-sponsored quick suggest results are subject to impression + // frequency caps. This pref is a fallback for the Nimbus variable + // `quickSuggestImpressionCapsNonSponsoredEnabled`. + ["quicksuggest.impressionCaps.nonSponsoredEnabled", false], + + // Whether sponsored quick suggest results are subject to impression frequency + // caps. This pref is a fallback for the Nimbus variable + // `quickSuggestImpressionCapsSponsoredEnabled`. + ["quicksuggest.impressionCaps.sponsoredEnabled", false], + + // JSON'ed object of quick suggest impression stats. Used for implementing + // impression frequency caps for quick suggest suggestions. + ["quicksuggest.impressionCaps.stats", ""], + + // The user's response to the Firefox Suggest online opt-in dialog. + ["quicksuggest.onboardingDialogChoice", ""], + + // If the user has gone through a quick suggest prefs migration, then this + // pref will have a user-branch value that records the latest prefs version. + // Version changelog: + // + // 0: (Unversioned) When `suggest.quicksuggest` is false, all quick suggest + // results are disabled and `suggest.quicksuggest.sponsored` is ignored. To + // show sponsored suggestions, both prefs must be true. + // + // 1: `suggest.quicksuggest` is removed, `suggest.quicksuggest.nonsponsored` + // is introduced. `suggest.quicksuggest.nonsponsored` and + // `suggest.quicksuggest.sponsored` are independent: + // `suggest.quicksuggest.nonsponsored` controls non-sponsored results and + // `suggest.quicksuggest.sponsored` controls sponsored results. + // `quicksuggest.dataCollection.enabled` is introduced. + // + // 2: For online, the defaults for `suggest.quicksuggest.nonsponsored` and + // `suggest.quicksuggest.sponsored` are true. Previously they were false. + ["quicksuggest.migrationVersion", 0], + + // Whether Remote Settings is enabled as a quick suggest source. + ["quicksuggest.remoteSettings.enabled", true], + + // The Firefox Suggest scenario in which the user is enrolled. This is set + // when the scenario is updated (see `updateFirefoxSuggestScenario`) and is + // not a pref the user should set. Once initialized, its value is one of: + // "history", "offline", "online" + ["quicksuggest.scenario", ""], + + // Whether the user has opted in to data collection for quick suggest. + ["quicksuggest.dataCollection.enabled", false], + + // The version of dialog user saw. + ["quicksuggest.onboardingDialogVersion", ""], + + // Whether to show the quick suggest onboarding dialog. + ["quicksuggest.shouldShowOnboardingDialog", true], + + // Whether the user has seen the onboarding dialog. + ["quicksuggest.showedOnboardingDialog", false], + + // Count the restarts before showing the onboarding dialog. + ["quicksuggest.seenRestarts", 0], + + // Whether quick suggest results can be shown in position specified in the + // suggestions. + ["quicksuggest.allowPositionInSuggestions", true], + + // Enable three-dot options button and menu for eligible results. + ["resultMenu", true], + + // Allow the result menu button to be reached with the Tab key. + ["resultMenu.keyboardAccessible", true], + + // When using switch to tabs, if set to true this will move the tab into the + // active window. + ["switchTabs.adoptIntoActiveWindow", false], + + // The number of remaining times the user can interact with tab-to-search + // onboarding results before we stop showing them. + ["tabToSearch.onboard.interactionsLeft", 3], + + // The number of times the user has been shown the onboarding search tip. + ["tipShownCount.searchTip_onboard", 0], + + // The number of times the user has been shown the urlbar persisted search tip. + ["tipShownCount.searchTip_persist", 0], + + // The number of times the user has been shown the redirect search tip. + ["tipShownCount.searchTip_redirect", 0], + + // Remove redundant portions from URLs. + ["trimURLs", true], + + // If true, top sites may include sponsored ones. + ["sponsoredTopSites", false], + + // Whether unit conversion is enabled. + ["unitConversion.enabled", false], + + // The index where we show unit conversion results. + ["unitConversion.suggestedIndex", 1], + + // Results will include a built-in set of popular domains when this is true. + ["usepreloadedtopurls.enabled", false], + + // After this many days from the profile creation date, the built-in set of + // popular domains will no longer be included in the results. + ["usepreloadedtopurls.expire_days", 14], + + // Controls the empty search behavior in Search Mode: + // 0 - Show nothing + // 1 - Show search history + // 2 - Show search and browsing history + ["update2.emptySearchBehavior", 0], + + // Feature gate pref for weather suggestions in the urlbar. + ["weather.featureGate", false], + + // When false, the weather suggestion will not be fetched when a VPN is + // detected. When true, it will be fetched anyway. + ["weather.ignoreVPN", false], + + // The minimum prefix length of a weather keyword the user must type to + // trigger the suggestion. 0 means the min length should be taken from Nimbus + // or remote settings. + ["weather.minKeywordLength", 0], + + // Feature gate pref for trending suggestions in the urlbar. + ["trending.featureGate", false], + + // Whether to only show trending results when the urlbar is in search + // mode or when the user initially opens the urlbar without selecting + // an engine. + ["trending.requireSearchMode", true], + + // The maximum number of trending results to show in search mode. + ["trending.maxResultsSearchMode", 10], + + // The maximum number of trending results to show while not in search mode. + ["trending.maxResultsNoSearchMode", 10], + + // Feature gate pref for rich suggestions being shown in the urlbar. + ["richSuggestions.featureGate", false], +]); + +const PREF_OTHER_DEFAULTS = new Map([ + ["browser.fixup.dns_first_for_single_words", false], + ["browser.search.suggest.enabled", true], + ["browser.search.suggest.enabled.private", false], + ["browser.search.widget.inNavBar", false], + ["keyword.enabled", true], + ["ui.popup.disable_autohide", false], +]); + +// Default values for Nimbus urlbar variables that do not have fallback prefs. +// Variables with fallback prefs do not need to be defined here because their +// defaults are the values of their fallbacks. +const NIMBUS_DEFAULTS = { + addonsShowLessFrequentlyCap: 0, + addonsUITreatment: "a", + experimentType: "", + isBestMatchExperiment: false, + quickSuggestRemoteSettingsDataType: "data", + recordNavigationalSuggestionTelemetry: false, + weatherKeywords: null, + weatherKeywordsMinimumLength: 0, + weatherKeywordsMinimumLengthCap: 0, +}; + +// Maps preferences under browser.urlbar.suggest to behavior names, as defined +// in mozIPlacesAutoComplete. +const SUGGEST_PREF_TO_BEHAVIOR = { + history: "history", + bookmark: "bookmark", + openpage: "openpage", + searches: "search", +}; + +const PREF_TYPES = new Map([ + ["boolean", "Bool"], + ["float", "Float"], + ["number", "Int"], + ["string", "Char"], +]); + +/** + * Builds the standard result groups and returns the root group. Result + * groups determine the composition of results in the muxer, i.e., how they're + * grouped and sorted. Each group is an object that looks like this: + * + * { + * {UrlbarUtils.RESULT_GROUP} [group] + * This is defined only on groups without children, and it determines the + * result group that the group will contain. + * {number} [maxResultCount] + * An optional maximum number of results the group can contain. If it's + * not defined and the parent group does not define `flexChildren: true`, + * then the max is the parent's max. If the parent group defines + * `flexChildren: true`, then `maxResultCount` is ignored. + * {boolean} [flexChildren] + * If true, then child groups are "flexed", similar to flex in HTML. Each + * child group should define the `flex` property (or, if they don't, `flex` + * is assumed to be zero). `flex` is a number that defines the ratio of a + * child's result count to the total result count of all children. More + * specifically, `flex: X` on a child means that the initial maximum result + * count of the child is `parentMaxResultCount * (X / N)`, where `N` is the + * sum of the `flex` values of all children. If there are any child groups + * that cannot be completely filled, then the muxer will attempt to overfill + * the children that were completely filled, while still respecting their + * relative `flex` values. + * {number} [flex] + * The flex value of the group. This should be defined only on groups + * where the parent defines `flexChildren: true`. See `flexChildren` for a + * discussion of flex. + * {array} [children] + * An array of child group objects. + * } + * + * @param {boolean} showSearchSuggestionsFirst + * If true, the suggestions group will come before the general group. + * @returns {object} + * The root group. + */ +function makeResultGroups({ showSearchSuggestionsFirst }) { + let rootGroup = { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_PRELOADED }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL }, + { group: lazy.UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: lazy.UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + ], + }; + + // Prepare the parent group for suggestions and general. + let mainGroup = { + flexChildren: true, + children: [ + // suggestions + { + children: [ + { + flexChildren: true, + children: [ + { + // If `maxHistoricalSearchSuggestions` == 0, the muxer forces + // `maxResultCount` to be zero and flex is ignored, per query. + flex: 2, + group: lazy.UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 4, + group: lazy.UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + group: lazy.UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION, + }, + ], + }, + // general + { + group: lazy.UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + children: [ + { + availableSpan: 3, + group: lazy.UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + { + flexChildren: true, + children: [ + { + flex: 1, + group: lazy.UrlbarUtils.RESULT_GROUP.REMOTE_TAB, + }, + { + flex: 2, + group: lazy.UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + // We show relatively many about-page results because they're + // only added for queries starting with "about:". + flex: 2, + group: lazy.UrlbarUtils.RESULT_GROUP.ABOUT_PAGES, + }, + { + flex: 1, + group: lazy.UrlbarUtils.RESULT_GROUP.PRELOADED, + }, + ], + }, + { + group: lazy.UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + ], + }, + ], + }; + if (!showSearchSuggestionsFirst) { + mainGroup.children.reverse(); + } + mainGroup.children[0].flex = 2; + mainGroup.children[1].flex = 1; + rootGroup.children.push(mainGroup); + + return rootGroup; +} + +/** + * Preferences class. The exported object is a singleton instance. + */ +class Preferences { + /** + * Constructor + */ + constructor() { + this._map = new Map(); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + + Services.prefs.addObserver(PREF_URLBAR_BRANCH, this, true); + for (let pref of PREF_OTHER_DEFAULTS.keys()) { + Services.prefs.addObserver(pref, this, true); + } + this._observerWeakRefs = []; + this.addObserver(this); + + // These prefs control the value of the shouldHandOffToSearchMode pref. They + // are exposed as a class variable so UrlbarPrefs observers can watch for + // changes in these prefs. + this.shouldHandOffToSearchModePrefs = [ + "keyword.enabled", + "suggest.searches", + ]; + + // This is resolved when the first update to the Firefox Suggest scenario + // (on startup) finishes. + this._firefoxSuggestScenarioStartupPromise = new Promise( + resolve => (this._resolveFirefoxSuggestScenarioStartupPromise = resolve) + ); + + // This is set to true when we update the Firefox Suggest scenario to + // prevent re-entry due to pref observers. + this._updatingFirefoxSuggestScenario = false; + + lazy.NimbusFeatures.urlbar.onUpdate(() => this._onNimbusUpdate()); + } + + /** + * Returns the value for the preference with the given name. + * For preferences in the "browser.urlbar."" branch, the passed-in name + * should be relative to the branch. It's also possible to get prefs from the + * PREF_OTHER_DEFAULTS Map, specifying their full name. + * + * @param {string} pref + * The name of the preference to get. + * @returns {*} The preference value. + */ + get(pref) { + let value = this._map.get(pref); + if (value === undefined) { + value = this._getPrefValue(pref); + this._map.set(pref, value); + } + return value; + } + + /** + * Sets the value for the preference with the given name. + * For preferences in the "browser.urlbar."" branch, the passed-in name + * should be relative to the branch. It's also possible to set prefs from the + * PREF_OTHER_DEFAULTS Map, specifying their full name. + * + * @param {string} pref + * The name of the preference to set. + * @param {*} value The preference value. + */ + set(pref, value) { + let { defaultValue, set } = this._getPrefDescriptor(pref); + if (typeof value != typeof defaultValue) { + throw new Error(`Invalid value type ${typeof value} for pref ${pref}`); + } + set(pref, value); + } + + /** + * Clears the value for the preference with the given name. + * + * @param {string} pref + * The name of the preference to clear. + */ + clear(pref) { + let { clear } = this._getPrefDescriptor(pref); + clear(pref); + } + + /** + * Builds the standard result groups. See makeResultGroups. + * + * @param {object} options + * See makeResultGroups. + * @returns {object} + * The root group. + */ + makeResultGroups(options) { + return makeResultGroups(options); + } + + get resultGroups() { + if (!this.#resultGroups) { + this.#resultGroups = makeResultGroups({ + showSearchSuggestionsFirst: this.get("showSearchSuggestionsFirst"), + }); + } + return this.#resultGroups; + } + + /** + * Sets the appropriate Firefox Suggest scenario based on the current Nimbus + * rollout (if any) and "hardcoded" rollouts (if any). The possible scenarios + * are: + * + * history + * This is the scenario when the user is not in any rollouts. Firefox + * Suggest suggestions are disabled. + * offline + * This is the scenario for the "offline" rollout. Firefox Suggest + * suggestions are enabled by default. Data collection is not enabled by + * default, but the user can opt in in about:preferences. The onboarding + * dialog is not shown. + * online + * This is the scenario for the "online" rollout. Firefox Suggest + * suggestions are enabled by default. Data collection is not enabled by + * default, and the user will be shown an onboarding dialog that prompts + * them to opt in to it. The user can also opt in in about:preferences. + * + * @param {string} [testOverrides] + * This is intended for tests only. Pass to force the following: + * `{ scenario, migrationVersion, defaultPrefs, isStartup }` + */ + async updateFirefoxSuggestScenario(testOverrides = null) { + // Make sure we don't re-enter this method while updating prefs. Updates to + // prefs that are fallbacks for Nimbus variables trigger the pref observer + // in Nimbus, which triggers our Nimbus `onUpdate` callback, which calls + // this method again. + if (this._updatingFirefoxSuggestScenario) { + return; + } + + let isStartup = + !this._updateFirefoxSuggestScenarioCalled || !!testOverrides?.isStartup; + this._updateFirefoxSuggestScenarioCalled = true; + + try { + this._updatingFirefoxSuggestScenario = true; + + // This is called early in startup by BrowserGlue, so make sure the user's + // region and our Nimbus variables are initialized since the scenario may + // depend on them. Also note that pref migrations may depend on the + // scenario, and since each migration is performed only once, at startup, + // prefs can end up wrong if their migrations use the wrong scenario. + await lazy.Region.init(); + await lazy.NimbusFeatures.urlbar.ready(); + this._clearNimbusCache(); + + // This also races TelemetryEnvironment's initialization, so wait for it + // to finish. TelemetryEnvironment is important because it records the + // values of a number of Suggest preferences. If we didn't wait, we could + // end up updating prefs after TelemetryEnvironment does its initial pref + // cache but before it adds its observer to be notified of pref changes. + // It would end up recording the wrong values on startup in that case. + if (!this._testSkipTelemetryEnvironmentInit) { + await lazy.TelemetryEnvironment.onInitialized(); + } + + this._updateFirefoxSuggestScenarioHelper(isStartup, testOverrides); + } finally { + this._updatingFirefoxSuggestScenario = false; + } + + // Null check `_resolveFirefoxSuggestScenarioStartupPromise` since some + // tests force `isStartup` after actual startup and it's been set to null. + if (isStartup && this._resolveFirefoxSuggestScenarioStartupPromise) { + this._resolveFirefoxSuggestScenarioStartupPromise(); + this._resolveFirefoxSuggestScenarioStartupPromise = null; + } + } + + _updateFirefoxSuggestScenarioHelper(isStartup, testOverrides) { + // Updating the scenario is tricky and it's important to preserve the user's + // choices, so we describe the process in detail below. tl;dr: + // + // * Prefs exposed in the UI should be sticky. + // * Prefs that are both exposed in the UI and configurable via Nimbus + // should be added to `uiPrefNamesByVariable` below. + // * Prefs that are both exposed in the UI and configurable via Nimbus don't + // need to be specified as a `fallbackPref` in the feature manifest. + // Access these prefs directly instead of through their Nimbus variables. + // * If you are modifying this method, keep in mind that setting a pref + // that's a `fallbackPref` for a Nimbus variable will trigger the pref + // observer inside Nimbus and call all `NimbusFeatures.urlbar.onUpdate` + // callbacks. Inside this class we guard against that by using + // `updatingFirefoxSuggestScenario`. + // + // The scenario-update process is described next. + // + // 1. Pick a scenario. If the user is in a Nimbus rollout, then Nimbus will + // define it. Otherwise the user may be in a "hardcoded" rollout + // depending on their region and locale. If the user is not in any + // rollouts, then the scenario is "history", which means no Firefox + // Suggest suggestions should appear. + // + // 2. Set prefs on the default branch appropriate for the scenario. We use + // the default branch and not the user branch because conceptually each + // scenario has a default behavior, which we want to distinguish from the + // user's choices. + // + // In particular it's important to consider prefs that are exposed in the + // UI, like whether sponsored suggestions are enabled. Once the user + // makes a choice to change a default, we want to preserve that choice + // indefinitely regardless of the scenario the user is currently enrolled + // in or future scenarios they might be enrolled in. User choices are of + // course recorded on the user branch, so if we set scenario defaults on + // the user branch too, we wouldn't be able to distinguish user choices + // from default values. This is also why prefs that are exposed in the UI + // should be sticky. Unlike non-sticky prefs, sticky prefs retain their + // user-branch values even when those values are the same as the ones on + // the default branch. + // + // It's important to note that the defaults we set here do not persist + // across app restarts. (This is a feature of the pref service; prefs set + // programmatically on the default branch are not stored anywhere + // permanent like firefox.js or user.js.) That's why BrowserGlue calls + // `updateFirefoxSuggestScenario` on every startup. + // + // 3. Some prefs are both exposed in the UI and configurable via Nimbus, + // like whether data collection is enabled. We absolutely want to + // preserve the user's past choices for these prefs. But if the user + // hasn't yet made a choice for a particular pref, then it should be + // configurable. + // + // For any such prefs that have values defined in Nimbus, we set their + // default-branch values to their Nimbus values. (These defaults + // therefore override any set in the previous step.) If a pref has a user + // value, accessing the pref will return the user value; if it does not + // have a user value, accessing it will return the value that was + // specified in Nimbus. + // + // This isn't strictly necessary. Since prefs exposed in the UI are + // sticky, they will always preserve their user-branch values regardless + // of their default-branch values, and as long as a pref is listed as a + // `fallbackPref` for its corresponding Nimbus variable, Nimbus will use + // the user-branch value. So we could instead specify fallback prefs in + // Nimbus and always access values through Nimbus instead of through + // prefs. But that would make preferences UI code a little harder to + // write since the checked state of a checkbox would depend on something + // other than its pref. Since we're already setting default-branch values + // here as part of the previous step, it's not much more work to set + // defaults for these prefs too, and it makes the UI code a little nicer. + // + // 4. Migrate prefs as necessary. This refers to any pref changes that are + // neccesary across app versions: introducing and initializing new prefs, + // removing prefs, or changing the meaning of existing prefs. + + // 1. Pick a scenario + let scenario = + testOverrides?.scenario || this._getIntendedFirefoxSuggestScenario(); + + // 2. Set default-branch values for the scenario + let defaultPrefs = + testOverrides?.defaultPrefs || this.FIREFOX_SUGGEST_DEFAULT_PREFS; + let prefs = { ...defaultPrefs[scenario] }; + + // 3. Set default-branch values for prefs that are both exposed in the UI + // and configurable via Nimbus + for (let [variable, prefName] of Object.entries( + this.FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE + )) { + if (this._nimbus.hasOwnProperty(variable)) { + prefs[prefName] = this._nimbus[variable]; + } + } + + let defaults = Services.prefs.getDefaultBranch("browser.urlbar."); + for (let [name, value] of Object.entries(prefs)) { + // We assume all prefs are boolean right now. + defaults.setBoolPref(name, value); + } + + // 4. Migrate prefs across app versions + if (isStartup) { + this._ensureFirefoxSuggestPrefsMigrated(scenario, testOverrides); + } + + // Set the scenario pref only after migrating so that migrations can tell + // what the last-seen scenario was. Set it on the user branch so that its + // value persists across app restarts. + this.set("quicksuggest.scenario", scenario); + } + + /** + * Returns the Firefox Suggest scenario the user should be enrolled in. This + * does *not* return the scenario they are currently enrolled in. + * + * @returns {string} + * The scenario the user should be enrolled in. + */ + _getIntendedFirefoxSuggestScenario() { + // If the user is in a Nimbus rollout, then Nimbus will define the scenario. + // Otherwise the user may be in a "hardcoded" rollout depending on their + // region and locale. If the user is not in any rollouts, then the scenario + // is "history", which means no Firefox Suggest suggestions will appear. + let scenario = this._nimbus.quickSuggestScenario; + if (!scenario) { + if ( + lazy.Region.home == "US" && + Services.locale.appLocaleAsBCP47.substring(0, 2) == "en" + ) { + // offline rollout for en locales in the US region + scenario = "offline"; + } else { + // no rollout + scenario = "history"; + } + } + if (!this.FIREFOX_SUGGEST_DEFAULT_PREFS.hasOwnProperty(scenario)) { + scenario = "history"; + console.error(`Unrecognized Firefox Suggest scenario "${scenario}"`); + } + return scenario; + } + + /** + * Prefs that are exposed in the UI and whose default-branch values are + * configurable via Nimbus variables. This getter returns an object that maps + * from variable names to pref names relative to `browser.urlbar`. See point 3 + * in the comment inside `_updateFirefoxSuggestScenarioHelper()` for more. + * + * @returns {{ quickSuggestNonSponsoredEnabled: string; quickSuggestSponsoredEnabled: string; quickSuggestDataCollectionEnabled: string; }} + */ + get FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE() { + return { + quickSuggestNonSponsoredEnabled: "suggest.quicksuggest.nonsponsored", + quickSuggestSponsoredEnabled: "suggest.quicksuggest.sponsored", + quickSuggestDataCollectionEnabled: "quicksuggest.dataCollection.enabled", + }; + } + + /** + * Default prefs relative to `browser.urlbar` per Firefox Suggest scenario. + * + * @returns {Record<Record<string, boolean>>} + */ + get FIREFOX_SUGGEST_DEFAULT_PREFS() { + // Important notes when modifying this: + // + // If you add a pref to one scenario, you typically need to add it to all + // scenarios even if the pref is in firefox.js. That's because we need to + // allow for switching from one scenario to another at any time after + // startup. If we set a pref for one scenario on the default branch, we + // switch to a new scenario, and we don't set the pref for the new scenario, + // it will keep its default-branch value from the old scenario. The only + // possible exception is for prefs that make others unnecessary, like how + // when `quicksuggest.enabled` is false, none of the other prefs matter. + // + // Prefs not listed here for any scenario keep their values set in + // firefox.js. + return { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }; + } + + /** + * The current version of the Firefox Suggest prefs. + * + * @returns {number} + */ + get FIREFOX_SUGGEST_MIGRATION_VERSION() { + return 2; + } + + /** + * Migrates Firefox Suggest prefs to the current version if they haven't been + * migrated already. + * + * @param {string} scenario + * The current Firefox Suggest scenario. + * @param {string} testOverrides + * This is intended for tests only. Pass to force a migration version: + * `{ migrationVersion }` + */ + _ensureFirefoxSuggestPrefsMigrated(scenario, testOverrides) { + let currentVersion = + testOverrides?.migrationVersion !== undefined + ? testOverrides.migrationVersion + : this.FIREFOX_SUGGEST_MIGRATION_VERSION; + let lastSeenVersion = Math.max( + 0, + this.get("quicksuggest.migrationVersion") + ); + if (currentVersion <= lastSeenVersion) { + // Migration up to date. + return; + } + + let version = lastSeenVersion; + + // When the current scenario is online and the last-seen prefs version is + // unversioned, specially handle migration up to version 2. + if (!version && scenario == "online" && 2 <= currentVersion) { + this._migrateFirefoxSuggestPrefsUnversionedTo2Online(); + version = 2; + } + + // Migrate from the last-seen version up to the current version. + for (; version < currentVersion; version++) { + let nextVersion = version + 1; + let methodName = "_migrateFirefoxSuggestPrefsTo_" + nextVersion; + try { + this[methodName](scenario); + } catch (error) { + console.error( + `Error migrating Firefox Suggest prefs to version ${nextVersion}: ` + + error + ); + break; + } + } + + // Record the new last-seen migration version. + this.set("quicksuggest.migrationVersion", version); + } + + /** + * Migrates unversioned Firefox Suggest prefs to version 2 but only when the + * user's current scenario is online. This case requires special handling that + * isn't covered by the usual migration path from unversioned to 2. + */ + _migrateFirefoxSuggestPrefsUnversionedTo2Online() { + // Copy `suggest.quicksuggest` to `suggest.quicksuggest.nonsponsored` and + // clear the first. + let mainPref = "browser.urlbar.suggest.quicksuggest"; + let mainPrefHasUserValue = Services.prefs.prefHasUserValue(mainPref); + if (mainPrefHasUserValue) { + this.set( + "suggest.quicksuggest.nonsponsored", + Services.prefs.getBoolPref(mainPref) + ); + Services.prefs.clearUserPref(mainPref); + } + + if (!this.get("quicksuggest.showedOnboardingDialog")) { + // The user was enrolled in history or offline, or they were enrolled in + // online and weren't shown the modal yet. + // + // If they were in history, they should now see suggestions by default, + // and we don't need to worry about any current pref values since Firefox + // Suggest is new to them. + // + // If they were in offline, they saw suggestions by default, but if they + // disabled the main suggestions pref, then both non-sponsored and + // sponsored suggestions were disabled and we need to carry that forward. + // + // If they were in online and weren't shown the modal yet, suggestions + // were disabled by default. The modal is shown only on startup, so it's + // possible they used Firefox for quite a while after being enrolled in + // online with suggestions disabled the whole time. If they looked at the + // prefs UI, they would have seen both suggestion checkboxes unchecked. + // For these users, ideally we wouldn't suddenly enable suggestions, but + // unfortunately there's no simple way to distinguish them from history + // and offline users at this point based on the unversioned prefs. We + // could check whether the user is or was enrolled in the initial online + // experiment; if they were, then disable suggestions. However, that's a + // little risky because it assumes future online rollouts will be + // delivered by new experiments and not by increasing the original + // experiment's population. If that assumption does not hold, we would end + // up disabling suggestions for all users who are newly enrolled in online + // even if they were previously in history or offline. Further, based on + // telemetry data at the time of writing, only a small number of users in + // online have not yet seen the modal. Therefore we will enable + // suggestions for these users too. + // + // Note that if the user is in online and hasn't been shown the modal yet, + // we'll show it at some point during startup right after this. However, + // starting with the version-2 prefs, the modal now opts the user in to + // only data collection, not suggestions as it previously did. + + if ( + mainPrefHasUserValue && + !this.get("suggest.quicksuggest.nonsponsored") + ) { + // The user was in offline and disabled the main suggestions pref, so + // sponsored suggestions were automatically disabled too. We know they + // disabled the main pref since it has a false user-branch value. + this.set("suggest.quicksuggest.sponsored", false); + } + return; + } + + // At this point, the user was in online, they were shown the modal, and the + // current scenario is online. In the unversioned prefs for online, the + // suggestion prefs were false on the default branch, but in the version-2 + // prefs, they're true on the default branch. + + if (mainPrefHasUserValue && this.get("suggest.quicksuggest.nonsponsored")) { + // The main pref is true on the user branch. The user opted in either via + // the modal or by checking the checkbox in the prefs UI. In the latter + // case, they were shown some informational text about data collection + // under the checkbox. Either way, they've opted in to data collection. + this.set("quicksuggest.dataCollection.enabled", true); + if ( + !Services.prefs.prefHasUserValue( + "browser.urlbar.suggest.quicksuggest.sponsored" + ) + ) { + // The sponsored pref does not have a user value, so the default-branch + // false value was the effective value and the user did not see + // sponsored suggestions. We need to override the version-2 default- + // branch true value by setting the pref to false. + this.set("suggest.quicksuggest.sponsored", false); + } + } else { + // The main pref is not true on the user branch, so the user either did + // not opt in or they later disabled suggestions in the prefs UI. Set the + // suggestion prefs to false on the user branch to override the version-2 + // default-branch true values. The data collection pref is false on the + // default branch, but since the user was shown the modal, set it on the + // user branch too, where it's sticky, to record the user's choice not to + // opt in. + this.set("suggest.quicksuggest.nonsponsored", false); + this.set("suggest.quicksuggest.sponsored", false); + this.set("quicksuggest.dataCollection.enabled", false); + } + } + + _migrateFirefoxSuggestPrefsTo_1(scenario) { + // Copy `suggest.quicksuggest` to `suggest.quicksuggest.nonsponsored` and + // clear the first. + let suggestQuicksuggest = "browser.urlbar.suggest.quicksuggest"; + if (Services.prefs.prefHasUserValue(suggestQuicksuggest)) { + this.set( + "suggest.quicksuggest.nonsponsored", + Services.prefs.getBoolPref(suggestQuicksuggest) + ); + Services.prefs.clearUserPref(suggestQuicksuggest); + } + + // In the unversioned prefs, sponsored suggestions were shown only if the + // main suggestions pref `suggest.quicksuggest` was true, but now there are + // two independent prefs, so disable sponsored if the main pref was false. + if (!this.get("suggest.quicksuggest.nonsponsored")) { + switch (scenario) { + case "offline": + // Set the pref on the user branch. Suggestions are enabled by default + // for offline; we want to preserve the user's choice of opting out, + // and we want to preserve the default-branch true value. + this.set("suggest.quicksuggest.sponsored", false); + break; + case "online": + // If the user-branch value is true, clear it so the default-branch + // false value becomes the effective value. + if (this.get("suggest.quicksuggest.sponsored")) { + this.clear("suggest.quicksuggest.sponsored"); + } + break; + } + } + + // The data collection pref is new in this version. Enable it iff the + // scenario is online and the user opted in to suggestions. In offline, it + // should always start off false. + if (scenario == "online" && this.get("suggest.quicksuggest.nonsponsored")) { + this.set("quicksuggest.dataCollection.enabled", true); + } + } + + _migrateFirefoxSuggestPrefsTo_2(scenario) { + // In previous versions of the prefs for online, suggestions were disabled + // by default; in version 2, they're enabled by default. For users who were + // already in online and did not enable suggestions (because they did not + // opt in, they did opt in but later disabled suggestions, or they were not + // shown the modal) we don't want to suddenly enable them, so if the prefs + // do not have user-branch values, set them to false. + if (this.get("quicksuggest.scenario") == "online") { + if ( + !Services.prefs.prefHasUserValue( + "browser.urlbar.suggest.quicksuggest.nonsponsored" + ) + ) { + this.set("suggest.quicksuggest.nonsponsored", false); + } + if ( + !Services.prefs.prefHasUserValue( + "browser.urlbar.suggest.quicksuggest.sponsored" + ) + ) { + this.set("suggest.quicksuggest.sponsored", false); + } + } + } + + /** + * @returns {Promise} + * This can be used to wait until the initial Firefox Suggest scenario has + * been set on startup. It's resolved when the first call to + * `updateFirefoxSuggestScenario()` finishes. + */ + get firefoxSuggestScenarioStartupPromise() { + return this._firefoxSuggestScenarioStartupPromise; + } + + /** + * @returns {boolean} + * Whether the Firefox Suggest scenario is being updated. While true, + * changes to related prefs should be ignored, depending on the observer. + * Telemetry intended to capture user changes to the prefs should not be + * recorded, for example. + */ + get updatingFirefoxSuggestScenario() { + return this._updatingFirefoxSuggestScenario; + } + + /** + * Adds a preference observer. Observers are held weakly. + * + * @param {object} observer + * An object that may optionally implement one or both methods: + * - `onPrefChanged` invoked when one of the preferences listed here + * change. It will be passed the pref name. For prefs in the + * `browser.urlbar.` branch, the name will be relative to the branch. + * For other prefs, the name will be the full name. + * - `onNimbusChanged` invoked when a Nimbus value changes. It will be + * passed the name of the changed Nimbus variable. + */ + addObserver(observer) { + this._observerWeakRefs.push(Cu.getWeakReference(observer)); + } + + /** + * Removes a preference observer. + * + * @param {object} observer + * An observer previously added with `addObserver()`. + */ + removeObserver(observer) { + for (let i = 0; i < this._observerWeakRefs.length; i++) { + let obs = this._observerWeakRefs[i].get(); + if (obs && obs == observer) { + this._observerWeakRefs.splice(i, 1); + break; + } + } + } + + /** + * Observes preference changes. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + observe(subject, topic, data) { + let pref = data.replace(PREF_URLBAR_BRANCH, ""); + if (!PREF_URLBAR_DEFAULTS.has(pref) && !PREF_OTHER_DEFAULTS.has(pref)) { + return; + } + this.#notifyObservers("onPrefChanged", pref); + } + + /** + * Called when a pref tracked by UrlbarPrefs changes. + * + * @param {string} pref + * The name of the pref, relative to `browser.urlbar.` if the pref is + * in that branch. + */ + onPrefChanged(pref) { + this._map.delete(pref); + + // Some prefs may influence others. + switch (pref) { + case "autoFill.adaptiveHistory.useCountThreshold": + this._map.delete("autoFillAdaptiveHistoryUseCountThreshold"); + return; + case "showSearchSuggestionsFirst": + this.#resultGroups = null; + return; + } + + if (pref.startsWith("suggest.")) { + this._map.delete("defaultBehavior"); + } + + if (this.shouldHandOffToSearchModePrefs.includes(pref)) { + this._map.delete("shouldHandOffToSearchMode"); + } + } + + /** + * Called when the `NimbusFeatures.urlbar` value changes. + */ + _onNimbusUpdate() { + let oldNimbus = this._clearNimbusCache(); + let newNimbus = this._nimbus; + + // Callback to observers having onNimbusChanged. + for (let name in newNimbus) { + if (oldNimbus[name] != newNimbus[name]) { + this.#notifyObservers("onNimbusChanged", name); + } + } + + // If a change occurred to the Firefox Suggest scenario variable or any + // variables that correspond to prefs exposed in the UI, we need to update + // the scenario. + let variables = [ + "quickSuggestScenario", + ...Object.keys(this.FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE), + ]; + for (let name of variables) { + if (oldNimbus[name] != newNimbus[name]) { + this.updateFirefoxSuggestScenario(); + return; + } + } + + // If the current default-branch value of any pref is incorrect for the + // intended scenario, we need to update the scenario. + let scenario = this._getIntendedFirefoxSuggestScenario(); + let intendedDefaultPrefs = this.FIREFOX_SUGGEST_DEFAULT_PREFS[scenario]; + let defaults = Services.prefs.getDefaultBranch("browser.urlbar."); + for (let [name, value] of Object.entries(intendedDefaultPrefs)) { + // We assume all prefs are boolean right now. + if (defaults.getBoolPref(name) != value) { + this.updateFirefoxSuggestScenario(); + return; + } + } + } + + /** + * Clears cached Nimbus variables. The cache will be repopulated the next time + * `_nimbus` is accessed. + * + * @returns {object} + * The value of the cache before it was cleared. It's an object that maps + * from variable names to values. + */ + _clearNimbusCache() { + let nimbus = this.__nimbus; + if (nimbus) { + for (let key of Object.keys(nimbus)) { + this._map.delete(key); + } + this.__nimbus = null; + } + return nimbus || {}; + } + + get _nimbus() { + if (!this.__nimbus) { + this.__nimbus = lazy.NimbusFeatures.urlbar.getAllVariables({ + defaultValues: NIMBUS_DEFAULTS, + }); + } + return this.__nimbus; + } + + /** + * Returns the raw value of the given preference straight from Services.prefs. + * + * @param {string} pref + * The name of the preference to get. + * @returns {*} The raw preference value. + */ + _readPref(pref) { + let { defaultValue, get } = this._getPrefDescriptor(pref); + return get(pref, defaultValue); + } + + /** + * Returns a validated and/or fixed-up value of the given preference. The + * value may be validated for correctness, or it might be converted into a + * different value that is easier to work with than the actual value stored in + * the preferences branch. Not all preferences require validation or fixup. + * + * The values returned from this method are the values that are made public by + * this module. + * + * @param {string} pref + * The name of the preference to get. + * @returns {*} The validated and/or fixed-up preference value. + */ + _getPrefValue(pref) { + switch (pref) { + case "defaultBehavior": { + let val = 0; + for (let type of Object.keys(SUGGEST_PREF_TO_BEHAVIOR)) { + let behavior = `BEHAVIOR_${SUGGEST_PREF_TO_BEHAVIOR[ + type + ].toUpperCase()}`; + val |= + this.get("suggest." + type) && Ci.mozIPlacesAutoComplete[behavior]; + } + return val; + } + case "shouldHandOffToSearchMode": + return this.shouldHandOffToSearchModePrefs.some( + prefName => !this.get(prefName) + ); + case "autoFillAdaptiveHistoryUseCountThreshold": + const nimbusValue = + this._nimbus.autoFillAdaptiveHistoryUseCountThreshold; + return nimbusValue === undefined + ? this.get("autoFill.adaptiveHistory.useCountThreshold") + : parseFloat(nimbusValue); + } + return this._readPref(pref); + } + + /** + * Returns a descriptor of the given preference. + * + * @param {string} pref The preference to examine. + * @returns {object} An object describing the pref with the following shape: + * { defaultValue, get, set, clear } + */ + _getPrefDescriptor(pref) { + let branch = Services.prefs.getBranch(PREF_URLBAR_BRANCH); + let defaultValue = PREF_URLBAR_DEFAULTS.get(pref); + if (defaultValue === undefined) { + branch = Services.prefs; + defaultValue = PREF_OTHER_DEFAULTS.get(pref); + if (defaultValue === undefined) { + let nimbus = this._getNimbusDescriptor(pref); + if (nimbus) { + return nimbus; + } + throw new Error("Trying to access an unknown pref " + pref); + } + } + + let type; + if (!Array.isArray(defaultValue)) { + type = PREF_TYPES.get(typeof defaultValue); + } else { + if (defaultValue.length != 2) { + throw new Error("Malformed pref def: " + pref); + } + [defaultValue, type] = defaultValue; + type = PREF_TYPES.get(type); + } + if (!type) { + throw new Error("Unknown pref type: " + pref); + } + return { + defaultValue, + get: branch[`get${type}Pref`], + // Float prefs are stored as Char. + set: branch[`set${type == "Float" ? "Char" : type}Pref`], + clear: branch.clearUserPref, + }; + } + + /** + * Returns a descriptor for the given Nimbus property, if it exists. + * + * @param {string} name + * The name of the desired property in the object returned from + * NimbusFeatures.urlbar.getAllVariables(). + * @returns {object} + * An object describing the property's value with the following shape (same + * as _getPrefDescriptor()): + * { defaultValue, get, set, clear } + * If the property doesn't exist, null is returned. + */ + _getNimbusDescriptor(name) { + if (!this._nimbus.hasOwnProperty(name)) { + return null; + } + return { + defaultValue: this._nimbus[name], + get: () => this._nimbus[name], + set() { + throw new Error(`'${name}' is a Nimbus value and cannot be set`); + }, + clear() { + throw new Error(`'${name}' is a Nimbus value and cannot be cleared`); + }, + }; + } + + /** + * Initializes the showSearchSuggestionsFirst pref based on the matchGroups + * pref. This function can be removed when the corresponding UI migration in + * BrowserGlue.sys.mjs is no longer needed. + */ + initializeShowSearchSuggestionsFirstPref() { + let matchGroups = []; + let pref = Services.prefs.getCharPref("browser.urlbar.matchGroups", ""); + try { + matchGroups = pref.split(",").map(v => { + let group = v.split(":"); + return [group[0].trim().toLowerCase(), Number(group[1])]; + }); + } catch (ex) {} + let groupNames = matchGroups.map(group => group[0]); + let suggestionIndex = groupNames.indexOf("suggestion"); + let generalIndex = groupNames.indexOf("general"); + let showSearchSuggestionsFirst = + generalIndex < 0 || + (suggestionIndex >= 0 && suggestionIndex < generalIndex); + let oldValue = Services.prefs.getBoolPref( + "browser.urlbar.showSearchSuggestionsFirst" + ); + Services.prefs.setBoolPref( + "browser.urlbar.showSearchSuggestionsFirst", + showSearchSuggestionsFirst + ); + + // Pref observers aren't called when a pref is set to its current value, but + // we always want to set matchGroups to the appropriate default value via + // onPrefChanged, so call it now if necessary. This is really only + // necessary for tests since the only time this function is called outside + // of tests is by a UI migration in BrowserGlue. + if (oldValue == showSearchSuggestionsFirst) { + this.onPrefChanged("showSearchSuggestionsFirst"); + } + } + + /** + * Return whether or not persisted search terms is enabled. + * + * @returns {boolean} true: if enabled. + */ + isPersistedSearchTermsEnabled() { + return ( + this.get("showSearchTermsFeatureGate") && + this.get("showSearchTerms.enabled") && + !this.get("browser.search.widget.inNavBar") + ); + } + + #notifyObservers(method, changed) { + for (let i = 0; i < this._observerWeakRefs.length; ) { + let observer = this._observerWeakRefs[i].get(); + if (!observer) { + // The observer has been GC'ed, so remove it from our list. + this._observerWeakRefs.splice(i, 1); + continue; + } + if (method in observer) { + try { + observer[method](changed); + } catch (ex) { + console.error(ex); + } + } + ++i; + } + } + + #resultGroups = null; +} + +export var UrlbarPrefs = new Preferences(); diff --git a/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs b/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs new file mode 100644 index 0000000000..9b471d26c4 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderAboutPages.sys.mjs @@ -0,0 +1,82 @@ +/* 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/. */ + +/** + * This module exports a provider that offers about pages. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPagesUtils: "resource://gre/modules/AboutPagesUtils.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderAboutPages extends UrlbarProvider { + /** + * Unique name for the provider, used by the context to filter on providers. + * + * @returns {string} + */ + get name() { + return "AboutPages"; + } + + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return queryContext.trimmedSearchString.toLowerCase().startsWith("about:"); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + */ + startQuery(queryContext, addCallback) { + let searchString = queryContext.trimmedSearchString.toLowerCase(); + for (const aboutUrl of lazy.AboutPagesUtils.visibleAboutUrls) { + if (aboutUrl.startsWith(searchString)) { + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [aboutUrl, UrlbarUtils.HIGHLIGHT.TYPED], + url: [aboutUrl, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(aboutUrl), + }) + ); + addCallback(this, result); + } + } + } +} + +export var UrlbarProviderAboutPages = new ProviderAboutPages(); diff --git a/browser/components/urlbar/UrlbarProviderAliasEngines.sys.mjs b/browser/components/urlbar/UrlbarProviderAliasEngines.sys.mjs new file mode 100644 index 0000000000..da14203492 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderAliasEngines.sys.mjs @@ -0,0 +1,95 @@ +/* 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/. */ + +/** + * This module exports a provider that offers engines with aliases as heuristic + * results. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderAliasEngines extends UrlbarProvider { + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "AliasEngines"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + (!queryContext.restrictSource || + queryContext.restrictSource == lazy.UrlbarTokenizer.RESTRICT.SEARCH) && + !queryContext.searchMode && + queryContext.tokens.length + ); + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + let alias = queryContext.tokens[0]?.value; + let engine = await lazy.UrlbarSearchUtils.engineForAlias( + alias, + queryContext.searchString + ); + if (!engine || instance != this.queryInstance) { + return; + } + let query = UrlbarUtils.substringAfter(queryContext.searchString, alias); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engine.name, + keyword: alias, + query: query.trimStart(), + icon: engine.iconURI?.spec, + }) + ); + result.heuristic = true; + addCallback(this, result); + } +} + +export var UrlbarProviderAliasEngines = new ProviderAliasEngines(); diff --git a/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs new file mode 100644 index 0000000000..0802d71bcb --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderAutofill.sys.mjs @@ -0,0 +1,1096 @@ +/* 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/. */ + +/** + * This module exports a provider that provides an autofill result. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AboutPagesUtils: "resource://gre/modules/AboutPagesUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE = { + AUTOFILL_ORIGIN: 1, + AUTOFILL_URL: 2, + AUTOFILL_ADAPTIVE: 3, +}; + +// Constants to support an alternative frecency algorithm. +const ORIGIN_USE_ALT_FRECENCY = Services.prefs.getBoolPref( + "places.frecency.origins.alternative.featureGate", + false +); +const ORIGIN_FRECENCY_FIELD = ORIGIN_USE_ALT_FRECENCY + ? "alt_frecency" + : "frecency"; + +// `WITH` clause for the autofill queries. autofill_frecency_threshold.value is +// the mean of all moz_origins.frecency values + stddevMultiplier * one standard +// deviation. This is inlined directly in the SQL (as opposed to being a custom +// Sqlite function for example) in order to be as efficient as possible. +// For alternative frecency, a NULL frecency will be normalized to 0.0, and when +// it will graduate, it will likely become 1 (official frecency is NOT NULL). +// Thus we set a minimum threshold of 2.0, otherwise if all the visits are older +// than the cutoff, we end up checking 0.0 (frecency) >= 0.0 (threshold) and +// autofill everything instead of nothing. +const SQL_AUTOFILL_WITH = ORIGIN_USE_ALT_FRECENCY + ? ` + WITH + autofill_frecency_threshold(value) AS ( + SELECT IFNULL( + (SELECT value FROM moz_meta WHERE key = 'origin_alt_frecency_threshold'), + 2.0 + ) + ) + ` + : ` + WITH + frecency_stats(count, sum, squares) AS ( + SELECT + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_count') AS REAL), + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_sum') AS REAL), + CAST((SELECT IFNULL(value, 0.0) FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares') AS REAL) + ), + autofill_frecency_threshold(value) AS ( + SELECT + CASE count + WHEN 0 THEN 0.0 + WHEN 1 THEN sum + ELSE (sum / count) + (:stddevMultiplier * sqrt((squares - ((sum * sum) / count)) / count)) + END + FROM frecency_stats + ) + `; + +const SQL_AUTOFILL_FRECENCY_THRESHOLD = `host_frecency >= ( + SELECT value FROM autofill_frecency_threshold + )`; + +function originQuery(where) { + // `frecency`, `bookmarked` and `visited` are partitioned by the fixed host, + // without `www.`. `host_prefix` instead is partitioned by full host, because + // we assume a prefix may not work regardless of `www.`. + let selectVisited = where.includes("visited") + ? `MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND visit_count > 0 + )) OVER (PARTITION BY fixup_url(host)) > 0` + : "0"; + let selectTitle; + let joinBookmarks; + if (where.includes("bookmarked")) { + selectTitle = "ifnull(b.title, iif(h.frecency <> 0, h.title, NULL))"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = h.id"; + } else { + selectTitle = "iif(h.frecency <> 0, h.title, NULL)"; + joinBookmarks = ""; + } + return `/* do not warn (bug no): cannot use an index to sort */ + ${SQL_AUTOFILL_WITH}, + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + SELECT + id, + prefix, + first_value(prefix) OVER ( + PARTITION BY host ORDER BY ${ORIGIN_FRECENCY_FIELD} DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + IFNULL(${ + ORIGIN_USE_ALT_FRECENCY ? "avg(alt_frecency)" : "total(frecency)" + } OVER (PARTITION BY fixup_url(host)), 0.0), + ${ORIGIN_FRECENCY_FIELD}, + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 + )) OVER (PARTITION BY fixup_url(host)), + ${selectVisited} + FROM moz_origins o + WHERE prefix NOT IN ('about:', 'place:') + AND ((host BETWEEN :searchString AND :searchString || X'FFFF') + OR (host BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF')) + ), + matched_origin(host_fixed, url) AS ( + SELECT iif(instr(host, :searchString) = 1, host, fixed) || '/', + ifnull(:prefix, host_prefix) || host || '/' + FROM origins + ${where} + ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + LIMIT 1 + ), + matched_place(host_fixed, url, id, title, frecency) AS ( + SELECT o.host_fixed, o.url, h.id, h.title, h.frecency + FROM matched_origin o + LEFT JOIN moz_places h ON h.url_hash IN ( + hash('https://' || o.host_fixed), + hash('https://www.' || o.host_fixed), + hash('http://' || o.host_fixed), + hash('http://www.' || o.host_fixed) + ) + ORDER BY + h.title IS NOT NULL DESC, + h.title || '/' <> o.host_fixed DESC, + h.url = o.url DESC, + h.frecency DESC, + h.id DESC + LIMIT 1 + ) + SELECT :query_type AS query_type, + :searchString AS search_string, + h.host_fixed AS host_fixed, + h.url AS url, + ${selectTitle} AS title + FROM matched_place h + ${joinBookmarks} + `; +} + +function urlQuery(where1, where2, isBookmarkContained) { + // We limit the search to places that are either bookmarked or have a frecency + // over some small, arbitrary threshold (20) in order to avoid scanning as few + // rows as possible. Keep in mind that we run this query every time the user + // types a key when the urlbar value looks like a URL with a path. + let selectTitle; + let joinBookmarks; + if (isBookmarkContained) { + selectTitle = "ifnull(b.title, matched_url.title)"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = matched_url.id"; + } else { + selectTitle = "matched_url.title"; + joinBookmarks = ""; + } + return `/* do not warn (bug no): cannot use an index to sort */ + WITH matched_url(url, title, frecency, bookmarked, visited, stripped_url, is_exact_match, id) AS ( + SELECT url, + title, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + strip_prefix_and_userinfo(url) AS stripped_url, + strip_prefix_and_userinfo(url) = strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, + id + FROM moz_places + WHERE rev_host = :revHost + ${where1} + UNION ALL + SELECT url, + title, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + strip_prefix_and_userinfo(url) AS stripped_url, + strip_prefix_and_userinfo(url) = 'www.' || strip_prefix_and_userinfo(:strippedURL) AS is_exact_match, + id + FROM moz_places + WHERE rev_host = :revHost || 'www.' + ${where2} + ORDER BY is_exact_match DESC, frecency DESC, id DESC + LIMIT 1 + ) + SELECT :query_type AS query_type, + :searchString AS search_string, + :strippedURL AS stripped_url, + matched_url.url AS url, + ${selectTitle} AS title + FROM matched_url + ${joinBookmarks} + `; +} + +// Queries +const QUERY_ORIGIN_HISTORY_BOOKMARK = originQuery( + `WHERE bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_PREFIX_HISTORY_BOOKMARK = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' + AND (bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})` +); + +const QUERY_ORIGIN_HISTORY = originQuery( + `WHERE visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_PREFIX_HISTORY = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' + AND visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}` +); + +const QUERY_ORIGIN_BOOKMARK = originQuery(`WHERE bookmarked`); + +const QUERY_ORIGIN_PREFIX_BOOKMARK = originQuery( + `WHERE prefix BETWEEN :prefix AND :prefix || X'FFFF' AND bookmarked` +); + +const QUERY_URL_HISTORY_BOOKMARK = urlQuery( + `AND (bookmarked OR frecency > 20) + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (bookmarked OR frecency > 20) + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_PREFIX_HISTORY_BOOKMARK = urlQuery( + `AND (bookmarked OR frecency > 20) + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND (bookmarked OR frecency > 20) + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_HISTORY = urlQuery( + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + false +); + +const QUERY_URL_PREFIX_HISTORY = urlQuery( + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + false +); + +const QUERY_URL_BOOKMARK = urlQuery( + `AND bookmarked + AND stripped_url COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND bookmarked + AND stripped_url COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'`, + true +); + +const QUERY_URL_PREFIX_BOOKMARK = urlQuery( + `AND bookmarked + AND url COLLATE NOCASE + BETWEEN :prefix || :strippedURL AND :prefix || :strippedURL || X'FFFF'`, + `AND bookmarked + AND url COLLATE NOCASE + BETWEEN :prefix || 'www.' || :strippedURL AND :prefix || 'www.' || :strippedURL || X'FFFF'`, + true +); + +/** + * Class used to create the provider. + */ +class ProviderAutofill extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "Autofill"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + async isActive(queryContext) { + let instance = this.queryInstance; + + // This is usually reset on canceling or completing the query, but since we + // query in isActive, it may not have been canceled by the previous call. + // It is an object with values { result: UrlbarResult, instance: Query }. + // See the documentation for _getAutofillData for more information. + this._autofillData = null; + + // First of all, check for the autoFill pref. + if (!lazy.UrlbarPrefs.get("autoFill")) { + return false; + } + + if (!queryContext.allowAutofill) { + return false; + } + + if (queryContext.tokens.length != 1) { + return false; + } + + // Trying to autofill an extremely long string would be expensive, and + // not particularly useful since the filled part falls out of screen anyway. + if (queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH) { + return false; + } + + // autoFill can only cope with history, bookmarks, and about: entries. + if ( + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return false; + } + + // Autofill doesn't search tags or titles + if ( + queryContext.tokens.some( + t => + t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG || + t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE + ) + ) { + return false; + } + + [this._strippedPrefix, this._searchString] = UrlbarUtils.stripURLPrefix( + queryContext.searchString + ); + this._strippedPrefix = this._strippedPrefix.toLowerCase(); + + // Don't try to autofill if the search term includes any whitespace. + // This may confuse completeDefaultIndex cause the AUTOCOMPLETE_MATCH + // tokenizer ends up trimming the search string and returning a value + // that doesn't match it, or is even shorter. + if (lazy.UrlbarTokenizer.REGEXP_SPACES.test(queryContext.searchString)) { + return false; + } + + // Fetch autofill result now, rather than in startQuery. We do this so the + // muxer doesn't have to wait on autofill for every query, since startQuery + // will be guaranteed to return a result very quickly using this approach. + // Bug 1651101 is filed to improve this behaviour. + let result = await this._getAutofillResult(queryContext); + if (!result || instance != this.queryInstance) { + return false; + } + this._autofillData = { result, instance }; + return true; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + // Priority search results are restricting. + if ( + this._autofillData && + this._autofillData.instance == this.queryInstance && + this._autofillData.result.type == UrlbarUtils.RESULT_TYPE.SEARCH + ) { + return 1; + } + + return 0; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // Check if the query was cancelled while the autofill result was being + // fetched. We don't expect this to be true since we also check the instance + // in isActive and clear _autofillData in cancelQuery, but we sanity check it. + if ( + !this._autofillData || + this._autofillData.instance != this.queryInstance + ) { + this.logger.error("startQuery invoked with an invalid _autofillData"); + return; + } + + this._autofillData.result.heuristic = true; + addCallback(this, this._autofillData.result); + this._autofillData = null; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._autofillData?.instance == this.queryInstance) { + this._autofillData = null; + } + } + + /** + * Filters hosts by retaining only the ones over the autofill threshold, then + * sorts them by their frecency, and extracts the one with the highest value. + * + * @param {UrlbarQueryContext} queryContext The current queryContext. + * @param {Array} hosts Array of host names to examine. + * @returns {Promise<string?>} + * Resolved when the filtering is complete. Resolves with the top matching + * host, or null if not found. + */ + async getTopHostOverThreshold(queryContext, hosts) { + let db = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + let conditions = []; + // Pay attention to the order of params, since they are not named. + let params = [...hosts]; + if (!ORIGIN_USE_ALT_FRECENCY) { + params.unshift(lazy.UrlbarPrefs.get("autoFill.stddevMultiplier")); + } + let sources = queryContext.sources; + if ( + sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + conditions.push(`(bookmarked OR ${SQL_AUTOFILL_FRECENCY_THRESHOLD})`); + } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + conditions.push(`visited AND ${SQL_AUTOFILL_FRECENCY_THRESHOLD}`); + } else if (sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + conditions.push("bookmarked"); + } + + let rows = await db.executeCached( + ` + ${SQL_AUTOFILL_WITH}, + origins(id, prefix, host_prefix, host, fixed, host_frecency, frecency, bookmarked, visited) AS ( + SELECT + id, + prefix, + first_value(prefix) OVER ( + PARTITION BY host ORDER BY ${ORIGIN_FRECENCY_FIELD} DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + IFNULL(${ + ORIGIN_USE_ALT_FRECENCY ? "avg(alt_frecency)" : "total(frecency)" + } OVER (PARTITION BY fixup_url(host)), 0.0), + ${ORIGIN_FRECENCY_FIELD}, + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND foreign_count > 0 + )) OVER (PARTITION BY fixup_url(host)), + MAX(EXISTS( + SELECT 1 FROM moz_places WHERE origin_id = o.id AND visit_count > 0 + )) OVER (PARTITION BY fixup_url(host)) + FROM moz_origins o + WHERE o.host IN (${new Array(hosts.length).fill("?").join(",")}) + ) + SELECT host + FROM origins + ${conditions.length ? "WHERE " + conditions.join(" AND ") : ""} + ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + LIMIT 1 + `, + params + ); + if (!rows.length) { + return null; + } + return rows[0].getResultByName("host"); + } + + /** + * Obtains the query to search for autofill origin results. + * + * @param {UrlbarQueryContext} queryContext + * The current queryContext. + * @returns {Array} consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + _getOriginQuery(queryContext) { + // At this point, searchString is not a URL with a path; it does not + // contain a slash, except for possibly at the very end. If there is + // trailing slash, remove it when searching here to match the rest of the + // string because it may be an origin. + let searchStr = this._searchString.endsWith("/") + ? this._searchString.slice(0, -1) + : this._searchString; + + let opts = { + query_type: QUERYTYPE.AUTOFILL_ORIGIN, + searchString: searchStr.toLowerCase(), + }; + if (!ORIGIN_USE_ALT_FRECENCY) { + opts.stddevMultiplier = lazy.UrlbarPrefs.get("autoFill.stddevMultiplier"); + } + if (this._strippedPrefix) { + opts.prefix = this._strippedPrefix; + } + + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_HISTORY_BOOKMARK + : QUERY_ORIGIN_HISTORY_BOOKMARK, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_HISTORY + : QUERY_ORIGIN_HISTORY, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + return [ + this._strippedPrefix + ? QUERY_ORIGIN_PREFIX_BOOKMARK + : QUERY_ORIGIN_BOOKMARK, + opts, + ]; + } + throw new Error("Either history or bookmark behavior expected"); + } + + /** + * Obtains the query to search for autoFill url results. + * + * @param {UrlbarQueryContext} queryContext + * The current queryContext. + * @returns {Array} consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + _getUrlQuery(queryContext) { + // Try to get the host from the search string. The host is the part of the + // URL up to either the path slash, port colon, or query "?". If the search + // string doesn't look like it begins with a host, then return; it doesn't + // make sense to do a URL query with it. + const urlQueryHostRegexp = /^[^/:?]+/; + let hostMatch = urlQueryHostRegexp.exec(this._searchString); + if (!hostMatch) { + return [null, null]; + } + + let host = hostMatch[0].toLowerCase(); + let revHost = host.split("").reverse().join("") + "."; + + // Build a string that's the URL stripped of its prefix, i.e., the host plus + // everything after. Use queryContext.trimmedSearchString instead of + // this._searchString because this._searchString has had unEscapeURIForUI() + // called on it. It's therefore not necessarily the literal URL. + let strippedURL = queryContext.trimmedSearchString; + if (this._strippedPrefix) { + strippedURL = strippedURL.substr(this._strippedPrefix.length); + } + strippedURL = host + strippedURL.substr(host.length); + + let opts = { + query_type: QUERYTYPE.AUTOFILL_URL, + searchString: this._searchString, + revHost, + strippedURL, + }; + if (this._strippedPrefix) { + opts.prefix = this._strippedPrefix; + } + + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + return [ + this._strippedPrefix + ? QUERY_URL_PREFIX_HISTORY_BOOKMARK + : QUERY_URL_HISTORY_BOOKMARK, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY)) { + return [ + this._strippedPrefix ? QUERY_URL_PREFIX_HISTORY : QUERY_URL_HISTORY, + opts, + ]; + } + if (queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS)) { + return [ + this._strippedPrefix ? QUERY_URL_PREFIX_BOOKMARK : QUERY_URL_BOOKMARK, + opts, + ]; + } + throw new Error("Either history or bookmark behavior expected"); + } + + _getAdaptiveHistoryQuery(queryContext) { + let sourceCondition; + if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + sourceCondition = "(h.foreign_count > 0 OR h.frecency > 20)"; + } else if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.HISTORY) + ) { + sourceCondition = + "((h.visit_count > 0 OR h.foreign_count = 0) AND h.frecency > 20)"; + } else if ( + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS) + ) { + sourceCondition = "h.foreign_count > 0"; + } else { + return []; + } + + let selectTitle; + let joinBookmarks; + if (UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + selectTitle = "ifnull(b.title, matched.title)"; + joinBookmarks = "LEFT JOIN moz_bookmarks b ON b.fk = matched.id"; + } else { + selectTitle = "matched.title"; + joinBookmarks = ""; + } + + const params = { + queryType: QUERYTYPE.AUTOFILL_ADAPTIVE, + // `fullSearchString` is the value the user typed including a prefix if + // they typed one. `searchString` has been stripped of the prefix. + fullSearchString: queryContext.searchString.toLowerCase(), + searchString: this._searchString, + strippedPrefix: this._strippedPrefix, + useCountThreshold: lazy.UrlbarPrefs.get( + "autoFillAdaptiveHistoryUseCountThreshold" + ), + }; + + const query = ` + WITH matched(input, url, title, stripped_url, is_exact_match, starts_with, id) AS ( + SELECT + i.input AS input, + h.url AS url, + h.title AS title, + strip_prefix_and_userinfo(h.url) AS stripped_url, + strip_prefix_and_userinfo(h.url) = :searchString AS is_exact_match, + (strip_prefix_and_userinfo(h.url) COLLATE NOCASE BETWEEN :searchString AND :searchString || X'FFFF') AS starts_with, + h.id AS id + FROM moz_places h + JOIN moz_inputhistory i ON i.place_id = h.id + WHERE LENGTH(i.input) != 0 + AND :fullSearchString BETWEEN i.input AND i.input || X'FFFF' + AND ${sourceCondition} + AND i.use_count >= :useCountThreshold + AND (:strippedPrefix = '' OR get_prefix(h.url) = :strippedPrefix) + AND ( + starts_with OR + (stripped_url COLLATE NOCASE BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF') + ) + ORDER BY is_exact_match DESC, i.use_count DESC, h.frecency DESC, h.id DESC + LIMIT 1 + ) + SELECT + :queryType AS query_type, + :searchString AS search_string, + input, + url, + iif(starts_with, stripped_url, fixup_url(stripped_url)) AS url_fixed, + ${selectTitle} AS title, + stripped_url + FROM matched + ${joinBookmarks} + `; + + return [query, params]; + } + + /** + * Processes a matched row in the Places database. + * + * @param {object} row + * The matched row. + * @param {UrlbarQueryContext} queryContext + * The query context. + * @returns {UrlbarResult} a result generated from the matches row. + */ + _processRow(row, queryContext) { + let queryType = row.getResultByName("query_type"); + let title = row.getResultByName("title"); + + // `searchString` is `this._searchString` or derived from it. It is + // stripped, meaning the prefix (the URL protocol) has been removed. + let searchString = row.getResultByName("search_string"); + + // `fixedURL` is the part of the matching stripped URL that starts with the + // stripped search string. The important point here is "www" handling. If a + // stripped URL starts with "www", we allow the user to omit the "www" and + // still match it. So if the matching stripped URL starts with "www" but the + // stripped search string does not, `fixedURL` will also omit the "www". + // Otherwise `fixedURL` will be equivalent to the matching stripped URL. + // + // Example 1: + // stripped URL: www.example.com/ + // searchString: exam + // fixedURL: example.com/ + // Example 2: + // stripped URL: www.example.com/ + // searchString: www.exam + // fixedURL: www.example.com/ + // Example 3: + // stripped URL: example.com/ + // searchString: exam + // fixedURL: example.com/ + let fixedURL; + + // `finalCompleteValue` will be the UrlbarResult's URL. If the matching + // stripped URL starts with "www" but the user omitted it, + // `finalCompleteValue` will include it to properly reflect the real URL. + let finalCompleteValue; + + let autofilledType; + let adaptiveHistoryInput; + + switch (queryType) { + case QUERYTYPE.AUTOFILL_ORIGIN: { + fixedURL = row.getResultByName("host_fixed"); + finalCompleteValue = row.getResultByName("url"); + autofilledType = "origin"; + break; + } + case QUERYTYPE.AUTOFILL_URL: { + let url = row.getResultByName("url"); + let strippedURL = row.getResultByName("stripped_url"); + + if (!UrlbarUtils.canAutofillURL(url, strippedURL, true)) { + return null; + } + + // We autofill urls to-the-next-slash. + // http://mozilla.org/foo/bar/baz will be autofilled to: + // - http://mozilla.org/f[oo/] + // - http://mozilla.org/foo/b[ar/] + // - http://mozilla.org/foo/bar/b[az] + // And, toLowerCase() is preferred over toLocaleLowerCase() here + // because "COLLATE NOCASE" in the SQL only handles ASCII characters. + let strippedURLIndex = url + .toLowerCase() + .indexOf(strippedURL.toLowerCase()); + let strippedPrefix = url.substr(0, strippedURLIndex); + let nextSlashIndex = url.indexOf( + "/", + strippedURLIndex + strippedURL.length - 1 + ); + fixedURL = + nextSlashIndex < 0 + ? url.substr(strippedURLIndex) + : url.substring(strippedURLIndex, nextSlashIndex + 1); + finalCompleteValue = strippedPrefix + fixedURL; + if (finalCompleteValue !== url) { + title = null; + } + autofilledType = "url"; + break; + } + case QUERYTYPE.AUTOFILL_ADAPTIVE: { + adaptiveHistoryInput = row.getResultByName("input"); + fixedURL = row.getResultByName("url_fixed"); + finalCompleteValue = row.getResultByName("url"); + autofilledType = "adaptive"; + break; + } + } + + // Compute `autofilledValue`, the full value that will be placed in the + // input. It includes two parts: the part the user already typed in the + // character case they typed it (`queryContext.searchString`), and the + // autofilled part, which is the portion of the fixed URL starting after the + // stripped search string. + let autofilledValue = + queryContext.searchString + fixedURL.substring(searchString.length); + + // If more than an origin was autofilled and the user typed the full + // autofilled value, override the final URL by using the exact value the + // user typed. This allows the user to visit a URL that differs from the + // autofilled URL only in character case (for example "wikipedia.org/RAID" + // vs. "wikipedia.org/Raid") by typing the full desired URL. + if ( + queryType != QUERYTYPE.AUTOFILL_ORIGIN && + queryContext.searchString.length == autofilledValue.length + ) { + // Use `new URL().href` to lowercase the domain in the final completed + // URL. This isn't necessary since domains are case insensitive, but it + // looks nicer because it means the domain will remain lowercased in the + // input, and it also reflects the fact that Firefox will visit the + // lowercased name. + const originalCompleteValue = new URL(finalCompleteValue).href; + let strippedAutofilledValue = autofilledValue.substring( + this._strippedPrefix.length + ); + finalCompleteValue = new URL( + finalCompleteValue.substring( + 0, + finalCompleteValue.length - strippedAutofilledValue.length + ) + strippedAutofilledValue + ).href; + + // If the character case of except origin part of the original + // finalCompleteValue differs from finalCompleteValue that includes user's + // input, we set title null because it expresses different web page. + if (finalCompleteValue !== originalCompleteValue) { + title = null; + } + } + + let payload = { + url: [finalCompleteValue, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(finalCompleteValue), + }; + + if (title) { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + let [autofilled] = UrlbarUtils.stripPrefixAndTrim(finalCompleteValue, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + payload.fallbackTitle = [autofilled, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + + result.autofill = { + adaptiveHistoryInput, + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + type: autofilledType, + }; + return result; + } + + async _getAutofillResult(queryContext) { + // We may be autofilling an about: link. + let result = this._matchAboutPageForAutofill(queryContext); + if (result) { + return result; + } + + // It may also look like a URL we know from the database. + result = await this._matchKnownUrl(queryContext); + if (result) { + return result; + } + + // Or we may want to fill a search engine domain regardless of the threshold. + result = await this._matchSearchEngineDomain(queryContext); + if (result) { + return result; + } + + return null; + } + + _matchAboutPageForAutofill(queryContext) { + // Check that the typed query is at least one character longer than the + // about: prefix. + if (this._strippedPrefix != "about:" || !this._searchString) { + return null; + } + + for (const aboutUrl of lazy.AboutPagesUtils.visibleAboutUrls) { + if (aboutUrl.startsWith(`about:${this._searchString.toLowerCase()}`)) { + let [trimmedUrl] = UrlbarUtils.stripPrefixAndTrim(aboutUrl, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [trimmedUrl, UrlbarUtils.HIGHLIGHT.TYPED], + url: [aboutUrl, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(aboutUrl), + }) + ); + let autofilledValue = + queryContext.searchString + + aboutUrl.substring(queryContext.searchString.length); + result.autofill = { + type: "about", + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } + } + return null; + } + + async _matchKnownUrl(queryContext) { + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + if (!conn) { + return null; + } + + // We try to autofill with adaptive history first. + if ( + lazy.UrlbarPrefs.get("autoFillAdaptiveHistoryEnabled") && + lazy.UrlbarPrefs.get("autoFillAdaptiveHistoryMinCharsThreshold") <= + queryContext.searchString.length + ) { + const [query, params] = this._getAdaptiveHistoryQuery(queryContext); + if (query) { + const resultSet = await conn.executeCached(query, params); + if (resultSet.length) { + return this._processRow(resultSet[0], queryContext); + } + } + } + + // The adaptive history query is passed queryContext.searchString (the full + // search string), but the origin and URL queries are passed the prefix + // (this._strippedPrefix) and the rest of the search string + // (this._searchString) separately. The user must specify a non-prefix part + // to trigger origin and URL autofill. + if (!this._searchString.length) { + return null; + } + + // If search string looks like an origin, try to autofill against origins. + // Otherwise treat it as a possible URL. When the string has only one slash + // at the end, we still treat it as an URL. + let query, params; + if ( + lazy.UrlbarTokenizer.looksLikeOrigin(this._searchString, { + ignoreKnownDomains: true, + }) + ) { + [query, params] = this._getOriginQuery(queryContext); + } else { + [query, params] = this._getUrlQuery(queryContext); + } + + // _getUrlQuery doesn't always return a query. + if (query) { + let rows = await conn.executeCached(query, params); + if (rows.length) { + return this._processRow(rows[0], queryContext); + } + } + return null; + } + + async _matchSearchEngineDomain(queryContext) { + if ( + !lazy.UrlbarPrefs.get("autoFill.searchEngines") || + !this._searchString.length + ) { + return null; + } + + // enginesForDomainPrefix only matches against engine domains. + // Remove an eventual trailing slash from the search string (without the + // prefix) and check if the resulting string is worth matching. + // Later, we'll verify that the found result matches the original + // searchString and eventually discard it. + let searchStr = this._searchString; + if (searchStr.indexOf("/") == searchStr.length - 1) { + searchStr = searchStr.slice(0, -1); + } + // If the search string looks more like a url than a domain, bail out. + if ( + !lazy.UrlbarTokenizer.looksLikeOrigin(searchStr, { + ignoreKnownDomains: true, + }) + ) { + return null; + } + + // Since we are autofilling, we can only pick one matching engine. Use the + // first. + let engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(searchStr) + )[0]; + if (!engine) { + return null; + } + let url = engine.searchForm; + let domain = engine.searchUrlDomain; + // Verify that the match we got is acceptable. Autofilling "example/" to + // "example.com/" would not be good. + if ( + (this._strippedPrefix && !url.startsWith(this._strippedPrefix)) || + !(domain + "/").includes(this._searchString) + ) { + return null; + } + + // The value that's autofilled in the input is the prefix the user typed, if + // any, plus the portion of the engine domain that the user typed. Append a + // trailing slash too, as is usual with autofill. + let value = + this._strippedPrefix + domain.substr(domain.indexOf(searchStr)) + "/"; + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + icon: engine.iconURI?.spec, + }) + ); + let autofilledValue = + queryContext.searchString + + value.substring(queryContext.searchString.length); + result.autofill = { + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } +} + +export var UrlbarProviderAutofill = new ProviderAutofill(); diff --git a/browser/components/urlbar/UrlbarProviderBookmarkKeywords.sys.mjs b/browser/components/urlbar/UrlbarProviderBookmarkKeywords.sys.mjs new file mode 100644 index 0000000000..416e6238ba --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderBookmarkKeywords.sys.mjs @@ -0,0 +1,119 @@ +/* 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/. */ + +/** + * This module exports a provider that offers bookmarks with keywords. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderBookmarkKeywords extends UrlbarProvider { + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "BookmarkKeywords"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + (!queryContext.restrictSource || + queryContext.restrictSource == + lazy.UrlbarTokenizer.RESTRICT.BOOKMARK) && + !queryContext.searchMode && + queryContext.tokens.length + ); + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + */ + async startQuery(queryContext, addCallback) { + let keyword = queryContext.tokens[0]?.value; + + let searchString = UrlbarUtils.substringAfter( + queryContext.searchString, + keyword + ).trim(); + let { entry, url, postData } = await lazy.KeywordUtils.getBindableKeyword( + keyword, + searchString + ); + if (!entry || !url) { + return; + } + + let title; + if (entry.url.host && searchString) { + // If we have a search string, the result has the title + // "host: searchString". + title = UrlbarUtils.strings.formatStringFromName( + "bookmarkKeywordSearch", + [ + entry.url.host, + queryContext.tokens + .slice(1) + .map(t => t.value) + .join(" "), + ] + ); + } else { + title = UrlbarUtils.unEscapeURIForUI(url); + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.KEYWORD, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + input: queryContext.searchString, + postData, + icon: UrlbarUtils.getIconForUrl(entry.url), + }) + ); + result.heuristic = true; + addCallback(this, result); + } +} + +export var UrlbarProviderBookmarkKeywords = new ProviderBookmarkKeywords(); diff --git a/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs b/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs new file mode 100644 index 0000000000..ae1216428b --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderCalculator.sys.mjs @@ -0,0 +1,465 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +// This pref is relative to the `browser.urlbar` branch. +const ENABLED_PREF = "suggest.calculator"; + +const DYNAMIC_RESULT_TYPE = "calculator"; + +const VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "content", + tag: "span", + attributes: { class: "urlbarView-no-wrap" }, + children: [ + { + name: "icon", + tag: "img", + attributes: { class: "urlbarView-favicon" }, + }, + { + name: "input", + tag: "strong", + }, + { + name: "action", + tag: "span", + }, + ], + }, + ], +}; + +// Minimum number of parts of the expression before we show a result. +const MIN_EXPRESSION_LENGTH = 3; + +/** + * A provider that returns a suggested url to the user based on what + * they have currently typed so they can navigate directly. + */ +class ProviderCalculator extends UrlbarProvider { + constructor() { + super(); + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return DYNAMIC_RESULT_TYPE; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + queryContext.trimmedSearchString && + !queryContext.searchMode && + lazy.UrlbarPrefs.get(ENABLED_PREF) + ); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + */ + async startQuery(queryContext, addCallback) { + try { + // Calculator will throw when given an invalid expression, therefore + // addCallback will never be called. + let postfix = Calculator.infix2postfix(queryContext.searchString); + if (postfix.length < MIN_EXPRESSION_LENGTH) { + return; + } + let value = Calculator.evaluatePostfix(postfix); + const result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + value, + input: queryContext.searchString, + dynamicType: DYNAMIC_RESULT_TYPE, + } + ); + result.suggestedIndex = 1; + addCallback(this, result); + } catch (e) {} + } + + getViewUpdate(result) { + const viewUpdate = { + icon: { + attributes: { + src: "chrome://global/skin/icons/edit-copy.svg", + }, + }, + input: { + l10n: { + id: "urlbar-result-action-calculator-result", + args: { result: result.payload.value }, + }, + }, + action: { + l10n: { id: "urlbar-result-action-copy-to-clipboard" }, + }, + }; + + return viewUpdate; + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result } = details; + if (result?.providerName == this.name) { + lazy.ClipboardHelper.copyString(result.payload.value); + } + } +} + +/** + * Base implementation of a basic calculator. + */ +class BaseCalculator { + // Holds the current symbols for calculation + stack = []; + numberSystems = []; + + addNumberSystem(system) { + this.numberSystems.push(system); + } + + isNumeric(value) { + return value - 0 == value && value.length; + } + + isOperator(value) { + return this.numberSystems.some(sys => sys.isOperator(value)); + } + + isNumericToken(char) { + return this.numberSystems.some(sys => sys.isNumericToken(char)); + } + + parsel10nFloat(num) { + for (const system of this.numberSystems) { + num = system.transformNumber(num); + } + return parseFloat(num, 10); + } + + precedence(val) { + if (["-", "+"].includes(val)) { + return 2; + } + if (["*", "/"].includes(val)) { + return 3; + } + + return null; + } + + // This is a basic implementation of the shunting yard algorithm + // described http://en.wikipedia.org/wiki/Shunting-yard_algorithm + // Currently functions are unimplemented and only operators with + // left association are used + infix2postfix(infix) { + let parser = new Parser(infix, this); + let tokens = parser.parse(infix); + let output = []; + let stack = []; + + tokens.forEach(token => { + if (token.number) { + output.push(this.parsel10nFloat(token.value, 10)); + } + + if (this.isOperator(token.value)) { + let i = this.precedence; + while ( + stack.length && + this.isOperator(stack[stack.length - 1]) && + i(token.value) <= i(stack[stack.length - 1]) + ) { + output.push(stack.pop()); + } + stack.push(token.value); + } + + if (token.value === "(") { + stack.push(token.value); + } + + if (token.value === ")") { + while (stack.length && stack[stack.length - 1] !== "(") { + output.push(stack.pop()); + } + // This is the ( + stack.pop(); + } + }); + + while (stack.length) { + output.push(stack.pop()); + } + return output; + } + + evaluate = { + "*": (a, b) => a * b, + "+": (a, b) => a + b, + "-": (a, b) => a - b, + "/": (a, b) => a / b, + }; + + evaluatePostfix(postfix) { + let stack = []; + + postfix.forEach(token => { + if (!this.isOperator(token)) { + stack.push(token); + } else { + let op2 = stack.pop(); + let op1 = stack.pop(); + let result = this.evaluate[token](op1, op2); + if (isNaN(result)) { + throw new Error("Value is " + result); + } + stack.push(result); + } + }); + let finalResult = stack.pop(); + if (isNaN(finalResult)) { + throw new Error("Value is " + finalResult); + } + return finalResult; + } +} + +function Parser(input, calculator) { + this.calculator = calculator; + this.init(input); +} + +Parser.prototype = { + init(input) { + // No spaces. + input = input.replace(/[ \t\v\n]/g, ""); + + // String to array: + this._chars = []; + for (let i = 0; i < input.length; ++i) { + this._chars.push(input[i]); + } + + this._tokens = []; + }, + + // This method returns an array of objects with these properties: + // - number: true/false + // - value: the token value + parse(input) { + // The input must be a "block" without any digit left. + if (!this._tokenizeBlock() || this._chars.length) { + throw new Error("Wrong input"); + } + + return this._tokens; + }, + + _tokenizeBlock() { + if (!this._chars.length) { + return false; + } + + // "(" + something + ")" + if (this._chars[0] == "(") { + this._tokens.push({ number: false, value: this._chars[0] }); + this._chars.shift(); + + if (!this._tokenizeBlock()) { + return false; + } + + if (!this._chars.length || this._chars[0] != ")") { + return false; + } + + this._chars.shift(); + + this._tokens.push({ number: false, value: ")" }); + } else if (!this._tokenizeNumber()) { + // number + ... + return false; + } + + if (!this._chars.length || this._chars[0] == ")") { + return true; + } + + while (this._chars.length && this._chars[0] != ")") { + if (!this._tokenizeOther()) { + return false; + } + + if (!this._tokenizeBlock()) { + return false; + } + } + + return true; + }, + + // This is a simple float parser. + _tokenizeNumber() { + if (!this._chars.length) { + return false; + } + + // {+,-}something + let number = []; + if (/[+-]/.test(this._chars[0])) { + number.push(this._chars.shift()); + } + + let tokenizeNumberInternal = () => { + if ( + !this._chars.length || + !this.calculator.isNumericToken(this._chars[0]) + ) { + return false; + } + + while ( + this._chars.length && + this.calculator.isNumericToken(this._chars[0]) + ) { + number.push(this._chars.shift()); + } + + return true; + }; + + if (!tokenizeNumberInternal()) { + return false; + } + + // 123{e...} + if (!this._chars.length || this._chars[0] != "e") { + this._tokens.push({ number: true, value: number.join("") }); + return true; + } + + number.push(this._chars.shift()); + + // 123e{+,-} + if (/[+-]/.test(this._chars[0])) { + number.push(this._chars.shift()); + } + + if (!this._chars.length) { + return false; + } + + // the number + if (!tokenizeNumberInternal()) { + return false; + } + + this._tokens.push({ number: true, value: number.join("") }); + return true; + }, + + _tokenizeOther() { + if (!this._chars.length) { + return false; + } + + if (this.calculator.isOperator(this._chars[0])) { + this._tokens.push({ number: false, value: this._chars.shift() }); + return true; + } + + return false; + }, +}; + +export let Calculator = new BaseCalculator(); + +Calculator.addNumberSystem({ + isOperator: char => ["÷", "×", "-", "+", "*", "/"].includes(char), + isNumericToken: char => /^[0-9\.,]/.test(char), + // parseFloat will only handle numbers that use periods as decimal + // seperators, various countries use commas. This function attempts + // to fixup the number so parseFloat will accept it. + transformNumber: num => { + let firstComma = num.indexOf(","); + let firstPeriod = num.indexOf("."); + + if (firstPeriod != -1 && firstComma != -1 && firstPeriod < firstComma) { + // Contains both a period and a comma and the period came first + // so using comma as decimal seperator, strip . and replace , with . + // (ie 1.999,5). + num = num.replace(/\./g, ""); + num = num.replace(/,/g, "."); + } else if (firstPeriod != -1 && firstComma != -1) { + // Contains both a period and a comma and the comma came first + // so strip the comma (ie 1,999.5). + num = num.replace(/,/g, ""); + } else if (firstComma != -1) { + // Has commas but no periods so treat comma as decimal seperator + num = num.replace(/,/g, "."); + } + return num; + }, +}); + +export var UrlbarProviderCalculator = new ProviderCalculator(); diff --git a/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs new file mode 100644 index 0000000000..0b4ecd943d --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderContextualSearch.sys.mjs @@ -0,0 +1,283 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +const DYNAMIC_RESULT_TYPE = "contextualSearch"; + +const ENABLED_PREF = "contextualSearch.enabled"; + +const VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "no-wrap", + tag: "span", + classList: ["urlbarView-no-wrap"], + children: [ + { + name: "icon", + tag: "img", + classList: ["urlbarView-favicon"], + }, + { + name: "search", + tag: "span", + classList: ["urlbarView-title"], + }, + { + name: "separator", + tag: "span", + classList: ["urlbarView-title-separator"], + }, + { + name: "description", + tag: "span", + }, + ], + }, + ], +}; + +/** + * A provider that returns an option for using the search engine provided + * by the active view if it utilizes OpenSearch. + */ +class ProviderContextualSearch extends UrlbarProvider { + constructor() { + super(); + this.engines = new Map(); + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + * + * @returns {string} + */ + get name() { + return "UrlbarProviderContextualSearch"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + queryContext.trimmedSearchString && + !queryContext.searchMode && + lazy.UrlbarPrefs.get(ENABLED_PREF) + ); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + */ + async startQuery(queryContext, addCallback) { + let engine; + const hostname = + queryContext?.currentPage && new URL(queryContext.currentPage).hostname; + + // This happens on about pages, which won't have associated engines + if (!hostname) { + return; + } + + // First check to see if there's a cached search engine for the host. + // If not, check to see if an installed engine matches the current view. + if (this.engines.has(hostname)) { + engine = this.engines.get(hostname); + } else { + // Strip www. to allow for partial matches when looking for an engine. + const [host] = UrlbarUtils.stripPrefixAndTrim(hostname, { + stripWww: true, + }); + engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host, { + matchAllDomainLevels: true, + onlyEnabled: false, + }) + )[0]; + } + + if (engine) { + this.engines.set(hostname, engine); + // Check to see if the engine that was found is the default engine. + // The default engine will often be used to populate the heuristic result, + // and we want to avoid ending up with two nearly identical search results. + let defaultEngine = lazy.UrlbarSearchUtils.getDefaultEngine(); + if (engine.name === defaultEngine?.name) { + return; + } + const [url] = UrlbarUtils.getSearchQueryUrl( + engine, + queryContext.searchString + ); + let result = this.makeResult({ + url, + engine: engine.name, + icon: engine.iconURI?.spec, + input: queryContext.searchString, + shouldNavigate: true, + }); + addCallback(this, result); + return; + } + + // If the current view has engines that haven't been added, return a result + // that will first add an engine, then use it to search. + let window = lazy.BrowserWindowTracker.getTopWindow(); + let engineToAdd = window?.gBrowser.selectedBrowser?.engines?.[0]; + + if (engineToAdd) { + let result = this.makeResult({ + hostname, + url: engineToAdd.uri, + engine: engineToAdd.title, + icon: engineToAdd.icon, + input: queryContext.searchString, + shouldAddEngine: true, + }); + addCallback(this, result); + } + } + + makeResult({ + engine, + icon, + url, + input, + hostname, + shouldNavigate = false, + shouldAddEngine = false, + }) { + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + engine, + icon, + url, + input, + hostname, + shouldAddEngine, + shouldNavigate, + dynamicType: DYNAMIC_RESULT_TYPE, + } + ); + result.suggestedIndex = -1; + return result; + } + + /** + * This is called when the urlbar view updates the view of one of the results + * of the provider. It should return an object describing the view update. + * See the base UrlbarProvider class for more. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result, idsByName) { + return { + icon: { + attributes: { + src: result.payload.icon || UrlbarUtils.ICON.SEARCH_GLASS, + }, + }, + search: { + textContent: result.payload.input, + attributes: { + title: result.payload.input, + }, + }, + description: { + l10n: { + id: "urlbar-result-action-search-w-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + }; + } + + onEngagement(isPrivate, state, queryContext, details, window) { + let { result } = details; + if (result?.providerName == this.name) { + this.#pickResult(result, window); + } + } + + async #pickResult(result, window) { + // If we have an engine to add, first create a new OpenSearchEngine, then + // get and open a url to execute a search for the term in the url bar. + // In cases where we don't have to create a new engine, navigation is + // handled automatically by providing `shouldNavigate: true` in the result. + if (result.payload.shouldAddEngine) { + let newEngine = new lazy.OpenSearchEngine({ shouldPersist: false }); + newEngine._setIcon(result.payload.icon, false); + await new Promise(resolve => { + newEngine.install(result.payload.url, errorCode => { + resolve(errorCode); + }); + }); + this.engines.set(result.payload.hostname, newEngine); + const [url] = UrlbarUtils.getSearchQueryUrl( + newEngine, + result.payload.input + ); + window.gBrowser.fixupAndLoadURIString(url, { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + } + } +} + +export var UrlbarProviderContextualSearch = new ProviderContextualSearch(); diff --git a/browser/components/urlbar/UrlbarProviderExtension.sys.mjs b/browser/components/urlbar/UrlbarProviderExtension.sys.mjs new file mode 100644 index 0000000000..82b04240c1 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderExtension.sys.mjs @@ -0,0 +1,432 @@ +/* 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/. */ + +/** + * This module exports a provider class that is used for providers created by + * extensions. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +// The set of `UrlbarQueryContext` properties that aren't serializable. +const NONSERIALIZABLE_CONTEXT_PROPERTIES = new Set(["view"]); + +/** + * The browser.urlbar extension API allows extensions to create their own urlbar + * providers. The results from extension providers are integrated into the + * urlbar view just like the results from providers that are built into Firefox. + * + * This class is the interface between the provider-related parts of the + * browser.urlbar extension API implementation and our internal urlbar + * implementation. The API implementation should use this class to manage + * providers created by extensions. All extension providers must be instances + * of this class. + * + * When an extension requires a provider, the API implementation should call + * getOrCreate() to get or create it. When an extension adds an event listener + * related to a provider, the API implementation should call setEventListener() + * to register its own event listener with the provider. + */ +export class UrlbarProviderExtension extends UrlbarProvider { + /** + * Returns the extension provider with the given name, creating it first if + * it doesn't exist. + * + * @param {string} name + * The provider name. + * @returns {UrlbarProviderExtension} + * The provider. + */ + static getOrCreate(name) { + let provider = lazy.UrlbarProvidersManager.getProvider(name); + if (!provider) { + provider = new UrlbarProviderExtension(name); + lazy.UrlbarProvidersManager.registerProvider(provider); + } + return provider; + } + + /** + * Constructor. + * + * @param {string} name + * The provider's name. + */ + constructor(name) { + super(); + this._name = name; + this._eventListeners = new Map(); + this.behavior = "inactive"; + } + + /** + * The provider's name. + * + * @returns {string} + */ + get name() { + return this._name; + } + + /** + * The provider's type. The type of extension providers is always + * UrlbarUtils.PROVIDER_TYPE.EXTENSION. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.EXTENSION; + } + + /** + * Whether the provider should be invoked for the given context. If this + * method returns false, the providers manager won't start a query with this + * provider, to save on resources. + * + * @param {UrlbarQueryContext} context + * The query context object. + * @returns {boolean} + * Whether this provider should be invoked for the search. + */ + isActive(context) { + return this.behavior != "inactive"; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} context + * The query context object. + * @returns {number} + * The provider's priority for the given query. + */ + getPriority(context) { + // We give restricting extension providers a very high priority so that they + // normally override all built-in providers, but not Infinity so that we can + // still override them if necessary. + return this.behavior == "restricting" ? 999 : 0; + } + + /** + * Sets the listener function for an event. The extension API implementation + * should call this from its EventManager.register() implementations. Since + * EventManager.register() is called at most only once for each extension + * event (the first time the extension adds a listener for the event), each + * provider instance needs at most only one listener per event, and that's why + * this method is named setEventListener instead of addEventListener. + * + * The given listener function may return a promise that's resolved once the + * extension responds to the event, or if the event requires no response from + * the extension, it may return a non-promise value (possibly nothing). + * + * To remove the previously set listener, call this method again but pass null + * as the listener function. + * + * The event name should be one of the following: + * + * behaviorRequested + * This event is fired when the provider's behavior is needed from the + * extension. The listener should return a behavior string. + * queryCanceled + * This event is fired when an ongoing query is canceled. The listener + * shouldn't return anything. + * resultsRequested + * This event is fired when the provider's results are needed from the + * extension. The listener should return an array of results. + * + * @param {string} eventName + * The name of the event to listen to. + * @param {Function} listener + * The function that will be called when the event is fired. + */ + setEventListener(eventName, listener) { + if (listener) { + this._eventListeners.set(eventName, listener); + } else { + this._eventListeners.delete(eventName); + if (!this._eventListeners.size) { + lazy.UrlbarProvidersManager.unregisterProvider(this); + } + } + } + + /** + * This method is called by the providers manager before a query starts to + * update each extension provider's behavior. It fires the behaviorRequested + * event. + * + * @param {UrlbarQueryContext} context + * The query context. + */ + async updateBehavior(context) { + let behavior = await this._notifyListener( + "behaviorRequested", + makeSerializable(context) + ); + if (behavior) { + this.behavior = behavior; + } + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. See the base UrlbarProvider class for more. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser. + * @returns {object} An object describing the view update. + */ + async getViewUpdate(result, idsByName) { + return this._notifyListener("getViewUpdate", result, idsByName); + } + + /** + * This method is called by the providers manager when a query starts to fetch + * each extension provider's results. It fires the resultsRequested event. + * + * @param {UrlbarQueryContext} context + * The query context. + * @param {Function} addCallback + * The callback invoked by this method to add each result. + */ + async startQuery(context, addCallback) { + let extResults = await this._notifyListener( + "resultsRequested", + makeSerializable(context) + ); + if (extResults) { + for (let extResult of extResults) { + let result = await this._makeUrlbarResult(context, extResult).catch( + ex => this.logger.error(ex) + ); + if (result) { + addCallback(this, result); + } + } + } + } + + /** + * This method is called by the providers manager when an ongoing query is + * canceled. It fires the queryCanceled event. + * + * @param {UrlbarQueryContext} context + * The query context. + */ + cancelQuery(context) { + this._notifyListener("queryCanceled", makeSerializable(context)); + } + + #pickResult(result, element) { + let dynamicElementName = ""; + if (element && result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + dynamicElementName = element.getAttribute("name"); + } + this._notifyListener("resultPicked", result.payload, dynamicElementName); + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + */ + onEngagement(isPrivate, state, queryContext, details) { + let { result, element } = details; + // By design, the "resultPicked" extension event should not be fired when + // the picked element has a URL. + if (result?.providerName == this.name && !element?.dataset.url) { + this.#pickResult(result, element); + } + + this._notifyListener("engagement", isPrivate, state); + } + + /** + * Calls a listener function set by the extension API implementation, if any. + * + * @param {string} eventName + * The name of the listener to call (i.e., the name of the event to fire). + * @param {*} args + * Arguments to the listener function. + * @returns {*} + * The value returned by the listener function, if any. + */ + async _notifyListener(eventName, ...args) { + let listener = this._eventListeners.get(eventName); + if (!listener) { + return undefined; + } + let result; + try { + result = listener(...args); + } catch (error) { + this.logger.error(error); + return undefined; + } + if (result.catch) { + // The result is a promise, so wait for it to be resolved. Set up a timer + // so that we're not stuck waiting forever. + let timer = new SkippableTimer({ + name: "UrlbarProviderExtension notification timer", + time: lazy.UrlbarPrefs.get("extension.timeout"), + reportErrorOnTimeout: true, + logger: this.logger, + }); + result = await Promise.race([ + timer.promise, + result.catch(ex => this.logger.error(ex)), + ]); + timer.cancel(); + } + return result; + } + + /** + * Converts a plain-JS-object result created by the extension into a + * UrlbarResult object. + * + * @param {UrlbarQueryContext} context + * The query context. + * @param {object} extResult + * A plain JS object representing a result created by the extension. + * @returns {UrlbarResult} + * The UrlbarResult object. + */ + async _makeUrlbarResult(context, extResult) { + // If the result is a search result, make sure its payload has a valid + // `engine` property, which is the name of an engine, and which we use later + // on to look up the nsISearchEngine. We allow the extension to specify the + // engine by its name, alias, or domain. Prefer aliases over domains since + // one domain can have many engines. + if (extResult.type == "search") { + let engine; + if (extResult.payload.engine) { + // Validate the engine name by looking it up. + engine = Services.search.getEngineByName(extResult.payload.engine); + } else if (extResult.payload.keyword) { + // Look up the engine by its alias. + engine = await lazy.UrlbarSearchUtils.engineForAlias( + extResult.payload.keyword + ); + } else if (extResult.payload.url) { + // Look up the engine by its domain. + let host; + try { + host = new URL(extResult.payload.url).hostname; + } catch (err) {} + if (host) { + engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host) + )[0]; + } + } + if (!engine) { + // No engine found. + throw new Error("Invalid or missing engine specified by extension"); + } + extResult.payload.engine = engine.name; + } + + let type = UrlbarProviderExtension.RESULT_TYPES[extResult.type]; + if (type == UrlbarUtils.RESULT_TYPE.TIP) { + extResult.payload.type ||= "extension"; + extResult.payload.helpL10n = { + id: lazy.UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }; + } + + let result = new lazy.UrlbarResult( + UrlbarProviderExtension.RESULT_TYPES[extResult.type], + UrlbarProviderExtension.SOURCE_TYPES[extResult.source], + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + context.tokens, + extResult.payload || {} + ) + ); + if (extResult.heuristic && this.behavior == "restricting") { + // The muxer chooses the final heuristic result by taking the first one + // that claims to be the heuristic. We don't want extensions to clobber + // the default heuristic, so we allow this only if the provider is + // restricting. + result.heuristic = extResult.heuristic; + } + if (extResult.suggestedIndex !== undefined) { + result.suggestedIndex = extResult.suggestedIndex; + } + return result; + } +} + +// Maps extension result type enums to internal result types. +UrlbarProviderExtension.RESULT_TYPES = { + dynamic: UrlbarUtils.RESULT_TYPE.DYNAMIC, + keyword: UrlbarUtils.RESULT_TYPE.KEYWORD, + omnibox: UrlbarUtils.RESULT_TYPE.OMNIBOX, + remote_tab: UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + search: UrlbarUtils.RESULT_TYPE.SEARCH, + tab: UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + tip: UrlbarUtils.RESULT_TYPE.TIP, + url: UrlbarUtils.RESULT_TYPE.URL, +}; + +// Maps extension source type enums to internal source types. +UrlbarProviderExtension.SOURCE_TYPES = { + bookmarks: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + history: UrlbarUtils.RESULT_SOURCE.HISTORY, + local: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + network: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + search: UrlbarUtils.RESULT_SOURCE.SEARCH, + tabs: UrlbarUtils.RESULT_SOURCE.TABS, + actions: UrlbarUtils.RESULT_SOURCE.ACTIONS, +}; + +/** + * Returns a copy of a query context stripped of non-serializable properties. + * This is necessary because query contexts are passed to extensions where they + * become `Query` objects, as defined in the urlbar extensions schema. The + * WebExtensions framework automatically excludes serializable properties that + * aren't defined in the schema, but it chokes on non-serializable properties. + * + * @param {UrlbarQueryContext} context + * The query context. + * @returns {object} + * A copy of `context` with only serializable properties. + */ +function makeSerializable(context) { + return Object.fromEntries( + Object.entries(context).filter( + ([key]) => !NONSERIALIZABLE_CONTEXT_PROPERTIES.has(key) + ) + ); +} diff --git a/browser/components/urlbar/UrlbarProviderHeuristicFallback.sys.mjs b/browser/components/urlbar/UrlbarProviderHeuristicFallback.sys.mjs new file mode 100644 index 0000000000..af53360ba9 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderHeuristicFallback.sys.mjs @@ -0,0 +1,328 @@ +/* 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/. */ + +/** + * This module exports a provider that provides a heuristic result. The result + * either vists a URL or does a search with the current engine. This result is + * always the ultimate fallback for any query, so this provider is always active. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderHeuristicFallback extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "HeuristicFallback"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return true; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return 0; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let result = this._matchUnknownUrl(queryContext); + if (result) { + addCallback(this, result); + // Since we can't tell if this is a real URL and whether the user wants + // to visit or search for it, we provide an alternative searchengine + // match if the string looks like an alphanumeric origin or an e-mail. + let str = queryContext.searchString; + try { + new URL(str); + } catch (ex) { + if ( + lazy.UrlbarPrefs.get("keyword.enabled") && + (lazy.UrlbarTokenizer.looksLikeOrigin(str, { + noIp: true, + noPort: true, + }) || + lazy.UrlbarTokenizer.REGEXP_COMMON_EMAIL.test(str)) + ) { + let searchResult = this._engineSearchResult(queryContext); + if (instance != this.queryInstance) { + return; + } + addCallback(this, searchResult); + } + } + return; + } + + result = this._searchModeKeywordResult(queryContext); + if (result) { + addCallback(this, result); + return; + } + + result = this._engineSearchResult(queryContext); + if (instance != this.queryInstance) { + return; + } + if (result) { + result.heuristic = true; + addCallback(this, result); + } + } + + // TODO (bug 1054814): Use visited URLs to inform which scheme to use, if the + // scheme isn't specificed. + _matchUnknownUrl(queryContext) { + // The user may have typed something like "word?" to run a search. We + // should not convert that to a URL. We should also never convert actual + // URLs into URL results when search mode is active or a search mode + // restriction token was typed. + if ( + queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH || + lazy.UrlbarTokenizer.SEARCH_MODE_RESTRICT.has( + queryContext.restrictToken?.value + ) || + queryContext.searchMode + ) { + return null; + } + + let unescapedSearchString = UrlbarUtils.unEscapeURIForUI( + queryContext.searchString + ); + let [prefix, suffix] = UrlbarUtils.stripURLPrefix(unescapedSearchString); + if (!suffix && prefix) { + // The user just typed a stripped protocol, don't build a non-sense url + // like http://http/ for it. + return null; + } + + let searchUrl = queryContext.trimmedSearchString; + + if (queryContext.fixupError) { + if ( + queryContext.fixupError == Cr.NS_ERROR_MALFORMED_URI && + !lazy.UrlbarPrefs.get("keyword.enabled") + ) { + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + fallbackTitle: [searchUrl, UrlbarUtils.HIGHLIGHT.NONE], + url: [searchUrl, UrlbarUtils.HIGHLIGHT.NONE], + }) + ); + result.heuristic = true; + return result; + } + + return null; + } + + // If the URI cannot be fixed or the preferred URI would do a keyword search, + // that basically means this isn't useful to us. Note that + // fixupInfo.keywordAsSent will never be true if the keyword.enabled pref + // is false or there are no engines, so in that case we will always return + // a "visit". + if (!queryContext.fixupInfo?.href || queryContext.fixupInfo?.isSearch) { + return null; + } + + let uri = new URL(queryContext.fixupInfo.href); + // Check the host, as "http:///" is a valid nsIURI, but not useful to us. + // But, some schemes are expected to have no host. So we check just against + // schemes we know should have a host. This allows new schemes to be + // implemented without us accidentally blocking access to them. + let hostExpected = ["http:", "https:", "ftp:", "chrome:"].includes( + uri.protocol + ); + if (hostExpected && !uri.host) { + return null; + } + + // getFixupURIInfo() escaped the URI, so it may not be pretty. Embed the + // escaped URL in the result since that URL should be "canonical". But + // pass the pretty, unescaped URL as the result's title, since it is + // displayed to the user. + let escapedURL = uri.toString(); + let displayURL = decodeURI(uri); + + // We don't know if this url is in Places or not, and checking that would + // be expensive. Thus we also don't know if we may have an icon. + // If we'd just try to fetch the icon for the typed string, we'd cause icon + // flicker, since the url keeps changing while the user types. + // By default we won't provide an icon, but for the subset of urls with a + // host we'll check for a typed slash and set favicon for the host part. + let iconUri; + if (hostExpected && (searchUrl.endsWith("/") || uri.pathname.length > 1)) { + // Look for an icon with the entire URL except for the pathname, including + // scheme, usernames, passwords, hostname, and port. + let pathIndex = uri.toString().lastIndexOf(uri.pathname); + let prePath = uri.toString().slice(0, pathIndex); + iconUri = `page-icon:${prePath}/`; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + fallbackTitle: [displayURL, UrlbarUtils.HIGHLIGHT.NONE], + url: [escapedURL, UrlbarUtils.HIGHLIGHT.NONE], + icon: iconUri, + }) + ); + result.heuristic = true; + return result; + } + + _searchModeKeywordResult(queryContext) { + if (!queryContext.tokens.length) { + return null; + } + + let firstToken = queryContext.tokens[0].value; + if (!lazy.UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(firstToken)) { + return null; + } + + // At this point, the search string starts with a token that can be + // converted into search mode. + // Now we need to determine what to do based on the remainder of the search + // string. If the remainder starts with a space, then we should enter + // search mode, so we should continue below and create the result. + // Otherwise, we should not enter search mode, and in that case, the search + // string will look like one of the following: + // + // * The search string ends with the restriction token (e.g., the user + // has typed only the token by itself, with no trailing spaces). + // * More tokens exist, but there's no space between the restriction + // token and the following token. This is possible because the tokenizer + // does not require spaces between a restriction token and the remainder + // of the search string. In this case, we should not enter search mode. + // + // If we return null here and thereby do not enter search mode, then we'll + // continue on to _engineSearchResult, and the heuristic will be a + // default engine search result. + let query = UrlbarUtils.substringAfter( + queryContext.searchString, + firstToken + ); + if (!lazy.UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return null; + } + + let result; + if (queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) { + result = this._engineSearchResult(queryContext, firstToken); + } else { + result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + query: [query.trimStart(), UrlbarUtils.HIGHLIGHT.NONE], + keyword: [firstToken, UrlbarUtils.HIGHLIGHT.NONE], + }) + ); + } + result.heuristic = true; + return result; + } + + _engineSearchResult(queryContext, keyword = null) { + let engine; + if (queryContext.searchMode?.engineName) { + engine = lazy.UrlbarSearchUtils.getEngineByName( + queryContext.searchMode.engineName + ); + } else { + engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); + } + + if (!engine) { + return null; + } + + // Strip a leading search restriction char, because we prepend it to text + // when the search shortcut is used and it's not user typed. Don't strip + // other restriction chars, so that it's possible to search for things + // including one of those (e.g. "c#"). + let query = queryContext.searchString; + if ( + queryContext.tokens[0] && + queryContext.tokens[0].value === lazy.UrlbarTokenizer.RESTRICT.SEARCH + ) { + query = UrlbarUtils.substringAfter( + query, + queryContext.tokens[0].value + ).trim(); + } + + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + icon: engine.iconURI?.spec, + query: [query, UrlbarUtils.HIGHLIGHT.NONE], + keyword: keyword ? [keyword, UrlbarUtils.HIGHLIGHT.NONE] : undefined, + }) + ); + } +} + +export var UrlbarProviderHeuristicFallback = new ProviderHeuristicFallback(); diff --git a/browser/components/urlbar/UrlbarProviderHistoryUrlHeuristic.sys.mjs b/browser/components/urlbar/UrlbarProviderHistoryUrlHeuristic.sys.mjs new file mode 100644 index 0000000000..1523a45966 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderHistoryUrlHeuristic.sys.mjs @@ -0,0 +1,139 @@ +/* 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/. */ + +/** + * This module exports a provider that provides a heuristic result. The result + * will be provided if the query requests the URL and the URL is in Places with + * the page title. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderHistoryUrlHeuristic extends UrlbarProvider { + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "HistoryUrlHeuristic"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + // For better performance, this provider tries to return a result only when + // the input value can become a URL of the http(s) protocol and its length + // is less than `MAX_TEXT_LENGTH`. That way its SQL query avoids calling + // `hash()` on atypical or very long URLs. + return ( + queryContext.fixupInfo?.href && + !queryContext.fixupInfo.isSearch && + queryContext.fixupInfo.scheme.startsWith("http") && + queryContext.fixupInfo.href.length <= UrlbarUtils.MAX_TEXT_LENGTH + ); + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + const instance = this.queryInstance; + const result = await this.#getResult(queryContext); + if (result && instance === this.queryInstance) { + addCallback(this, result); + } + } + + async #getResult(queryContext) { + const inputedURL = queryContext.fixupInfo.href; + const [strippedURL] = UrlbarUtils.stripPrefixAndTrim(inputedURL, { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + }); + const connection = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + const resultSet = await connection.executeCached( + ` + SELECT url, title, frecency + FROM moz_places + WHERE + url_hash IN ( + hash('https://' || :strippedURL), + hash('https://www.' || :strippedURL), + hash('http://' || :strippedURL), + hash('http://www.' || :strippedURL) + ) + AND frecency <> 0 + ORDER BY + title IS NOT NULL DESC, + title || '/' <> :strippedURL DESC, + url = :inputedURL DESC, + frecency DESC, + id DESC + LIMIT 1 + `, + { inputedURL, strippedURL } + ); + + if (!resultSet.length) { + return null; + } + + const title = resultSet[0].getResultByName("title"); + if (!title) { + return null; + } + + return Object.assign( + new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [inputedURL, UrlbarUtils.HIGHLIGHT.TYPED], + title: [title, UrlbarUtils.HIGHLIGHT.NONE], + icon: UrlbarUtils.getIconForUrl(resultSet[0].getResultByName("url")), + }) + ), + { heuristic: true } + ); + } +} + +export var UrlbarProviderHistoryUrlHeuristic = + new ProviderHistoryUrlHeuristic(); diff --git a/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs new file mode 100644 index 0000000000..53b65d10d5 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderInputHistory.sys.mjs @@ -0,0 +1,227 @@ +/* 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/. */ + +/** + * This module exports a provider that offers input history (aka adaptive + * history) results. These results map typed search strings to Urlbar results. + * That way, a user can find a particular result again by typing the same + * string. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +// Sqlite result row index constants. +const QUERYINDEX = { + URL: 0, + TITLE: 1, + BOOKMARKED: 2, + BOOKMARKTITLE: 3, + TAGS: 4, + SWITCHTAB: 8, +}; + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED) +// - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE) +// - the tags associated with a bookmarked entry (QUERYINDEX_TAGS) +const SQL_BOOKMARK_TAGS_FRAGMENT = `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked, + ( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL + ORDER BY lastModified DESC LIMIT 1 + ) AS btitle, + ( SELECT GROUP_CONCAT(t.title, ', ') + FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent + WHERE b.fk = h.id + ) AS tags`; + +const SQL_ADAPTIVE_QUERY = `/* do not warn (bug 487789) */ + SELECT h.url, h.title, ${SQL_BOOKMARK_TAGS_FRAGMENT}, h.visit_count, + h.typed, h.id, t.open_count, h.frecency + FROM ( + SELECT ROUND(MAX(use_count) * (1 + (input = :search_string)), 1) AS rank, + place_id + FROM moz_inputhistory + WHERE input BETWEEN :search_string AND :search_string || X'FFFF' + GROUP BY place_id + ) AS i + JOIN moz_places h ON h.id = i.place_id + LEFT JOIN moz_openpages_temp t + ON t.url = h.url + AND t.userContextId = :userContextId + WHERE AUTOCOMPLETE_MATCH(NULL, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, bookmarked, + t.open_count, + :matchBehavior, :searchBehavior, + NULL) + ORDER BY rank DESC, h.frecency DESC + LIMIT :maxResults`; + +/** + * Class used to create the provider. + */ +class ProviderInputHistory extends UrlbarProvider { + /** + * Unique name for the provider, used by the context to filter on providers. + * + * @returns {string} + */ + get name() { + return "InputHistory"; + } + + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + (lazy.UrlbarPrefs.get("suggest.history") || + lazy.UrlbarPrefs.get("suggest.bookmark") || + lazy.UrlbarPrefs.get("suggest.openpage")) && + !queryContext.searchMode + ); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + if (instance != this.queryInstance) { + return; + } + + let [query, params] = this._getAdaptiveQuery(queryContext); + let rows = await conn.executeCached(query, params); + if (instance != this.queryInstance) { + return; + } + + for (let row of rows) { + const url = row.getResultByIndex(QUERYINDEX.URL); + const openPageCount = row.getResultByIndex(QUERYINDEX.SWITCHTAB) || 0; + const historyTitle = row.getResultByIndex(QUERYINDEX.TITLE) || ""; + const bookmarked = row.getResultByIndex(QUERYINDEX.BOOKMARKED); + const bookmarkTitle = bookmarked + ? row.getResultByIndex(QUERYINDEX.BOOKMARKTITLE) + : null; + const tags = row.getResultByIndex(QUERYINDEX.TAGS) || ""; + + let resultTitle = historyTitle; + if (openPageCount > 0 && lazy.UrlbarPrefs.get("suggest.openpage")) { + if (url == queryContext.currentPage) { + // Don't suggest switching to the current page. + continue; + } + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [resultTitle, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(url), + }) + ); + addCallback(this, result); + continue; + } + + let resultSource; + if (bookmarked && lazy.UrlbarPrefs.get("suggest.bookmark")) { + resultSource = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; + resultTitle = bookmarkTitle || historyTitle; + } else if (lazy.UrlbarPrefs.get("suggest.history")) { + resultSource = UrlbarUtils.RESULT_SOURCE.HISTORY; + } else { + continue; + } + + let resultTags = tags + .split(",") + .map(t => t.trim()) + .filter(tag => { + let lowerCaseTag = tag.toLocaleLowerCase(); + return queryContext.tokens.some(token => + lowerCaseTag.includes(token.lowerCaseValue) + ); + }) + .sort(); + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + resultSource, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [resultTitle, UrlbarUtils.HIGHLIGHT.TYPED], + tags: [resultTags, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(url), + }) + ); + + addCallback(this, result); + } + } + + /** + * Obtains the query to search for adaptive results. + * + * @param {UrlbarQueryContext} queryContext + * The current queryContext. + * @returns {Array} Contains the optimized query with which to search the + * database and an object containing the params to bound. + */ + _getAdaptiveQuery(queryContext) { + return [ + SQL_ADAPTIVE_QUERY, + { + parent: lazy.PlacesUtils.tagsFolderId, + search_string: queryContext.searchString.toLowerCase(), + matchBehavior: Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE, + searchBehavior: lazy.UrlbarPrefs.get("defaultBehavior"), + userContextId: + lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( + queryContext.userContextId, + queryContext.isPrivate + ), + maxResults: queryContext.maxResults, + }, + ]; + } +} + +export var UrlbarProviderInputHistory = new ProviderInputHistory(); diff --git a/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs new file mode 100644 index 0000000000..2c2b13d9cb --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderInterventions.sys.mjs @@ -0,0 +1,835 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs", + NLP: "resource://gre/modules/NLP.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "appUpdater", () => new lazy.AppUpdater()); + +// The possible tips to show. These names (except NONE) are used in the names +// of keys in the `urlbar.tips` keyed scalar telemetry (see telemetry.rst). +// Don't modify them unless you've considered that. If you do modify them or +// add new tips, then you are also adding new `urlbar.tips` keys and therefore +// need an expanded data collection review. +const TIPS = { + NONE: "", + CLEAR: "intervention_clear", + REFRESH: "intervention_refresh", + + // There's an update available, but the user's pref says we should ask them to + // download and apply it. + UPDATE_ASK: "intervention_update_ask", + + // The updater is currently checking. We don't actually show a tip for this, + // but we use it to tell whether we should wait for the check to complete in + // startQuery. See startQuery for details. + UPDATE_CHECKING: "intervention_update_checking", + + // The user's browser is up to date, but they triggered the update + // intervention. We show this special refresh intervention instead. + UPDATE_REFRESH: "intervention_update_refresh", + + // There's an update and it's been downloaded and applied. The user needs to + // restart to finish. + UPDATE_RESTART: "intervention_update_restart", + + // We can't update the browser or possibly even check for updates for some + // reason, so the user should download the latest version from the web. + UPDATE_WEB: "intervention_update_web", +}; + +const EN_LOCALE_MATCH = /^en(-.*)$/; + +// The search "documents" corresponding to each tip type. +const DOCUMENTS = { + clear: [ + "cache firefox", + "clear cache firefox", + "clear cache in firefox", + "clear cookies firefox", + "clear firefox cache", + "clear history firefox", + "cookies firefox", + "delete cookies firefox", + "delete history firefox", + "firefox cache", + "firefox clear cache", + "firefox clear cookies", + "firefox clear history", + "firefox cookie", + "firefox cookies", + "firefox delete cookies", + "firefox delete history", + "firefox history", + "firefox not loading pages", + "history firefox", + "how to clear cache", + "how to clear history", + ], + refresh: [ + "firefox crashing", + "firefox keeps crashing", + "firefox not responding", + "firefox not working", + "firefox refresh", + "firefox slow", + "how to reset firefox", + "refresh firefox", + "reset firefox", + ], + update: [ + "download firefox", + "download mozilla", + "firefox browser", + "firefox download", + "firefox for mac", + "firefox for windows", + "firefox free download", + "firefox install", + "firefox installer", + "firefox latest version", + "firefox mac", + "firefox quantum", + "firefox update", + "firefox version", + "firefox windows", + "get firefox", + "how to update firefox", + "install firefox", + "mozilla download", + "mozilla firefox 2019", + "mozilla firefox 2020", + "mozilla firefox download", + "mozilla firefox for mac", + "mozilla firefox for windows", + "mozilla firefox free download", + "mozilla firefox mac", + "mozilla firefox update", + "mozilla firefox windows", + "mozilla update", + "update firefox", + "update mozilla", + "www.firefox.com", + ], +}; + +// In order to determine whether we should show an update tip, we check for app +// updates, but only once per this time period. +const UPDATE_CHECK_PERIOD_MS = 12 * 60 * 60 * 1000; // 12 hours + +/** + * A node in the QueryScorer's phrase tree. + */ +class Node { + constructor(word) { + this.word = word; + this.documents = new Set(); + this.childrenByWord = new Map(); + } +} + +/** + * This class scores a query string against sets of phrases. To refer to a + * single set of phrases, we borrow the term "document" from search engine + * terminology. To use this class, first add your documents with `addDocument`, + * and then call `score` with a query string. `score` returns a sorted array of + * document-score pairs. + * + * The scoring method is fairly simple and is based on Levenshtein edit + * distance. Therefore, lower scores indicate a better match than higher + * scores. In summary, a query matches a phrase if the query starts with the + * phrase. So a query "firefox update foo bar" matches the phrase "firefox + * update" for example. A query matches a document if it matches any phrase in + * the document. The query and phrases are compared word for word, and we allow + * fuzzy matching by computing the Levenshtein edit distance in each comparison. + * The amount of fuzziness allowed is controlled with `distanceThreshold`. If + * the distance in a comparison is greater than this threshold, then the phrase + * does not match the query. The final score for a document is the minimum edit + * distance between its phrases and the query. + * + * As mentioned, `score` returns a sorted array of document-score pairs. It's + * up to you to filter the array to exclude scores above a certain threshold, or + * to take the top scorer, etc. + */ +export class QueryScorer { + /** + * @param {object} options + * Constructor options. + * @param {number} [options.distanceThreshold] + * Edit distances no larger than this value are considered matches. + * @param {Map} [options.variations] + * For convenience, the scorer can augment documents by replacing certain + * words with other words and phrases. This mechanism is called variations. + * This keys of this map are words that should be replaced, and the values + * are the replacement words or phrases. For example, if you add a document + * whose only phrase is "firefox update", normally the scorer will register + * only this single phrase for the document. However, if you pass the value + * `new Map(["firefox", ["fire fox", "fox fire", "foxfire"]])` for this + * parameter, it will register 4 total phrases for the document: "fire fox + * update", "fox fire update", "foxfire update", and the original "firefox + * update". + */ + constructor({ distanceThreshold = 1, variations = new Map() } = {}) { + this._distanceThreshold = distanceThreshold; + this._variations = variations; + this._documents = new Set(); + this._rootNode = new Node(); + } + + /** + * Adds a document to the scorer. + * + * @param {object} doc + * The document. + * @param {string} doc.id + * The document's ID. + * @param {Array} doc.phrases + * The set of phrases in the document. Each phrase should be a string. + */ + addDocument(doc) { + this._documents.add(doc); + + for (let phraseStr of doc.phrases) { + // Split the phrase and lowercase the words. + let phrase = phraseStr + .trim() + .split(/\s+/) + .map(word => word.toLocaleLowerCase()); + + // Build a phrase list that contains the original phrase plus its + // variations, if any. + let phrases = [phrase]; + for (let [triggerWord, variations] of this._variations) { + let index = phrase.indexOf(triggerWord); + if (index >= 0) { + for (let variation of variations) { + let variationPhrase = Array.from(phrase); + variationPhrase.splice(index, 1, ...variation.split(/\s+/)); + phrases.push(variationPhrase); + } + } + } + + // Finally, add the phrases to the phrase tree. + for (let completedPhrase of phrases) { + this._buildPhraseTree(this._rootNode, doc, completedPhrase, 0); + } + } + } + + /** + * Scores a query string against the documents in the scorer. + * + * @param {string} queryString + * The query string to score. + * @returns {Array} + * An array of objects: { document, score }. Each element in the array is a + * a document and its score against the query string. The elements are + * ordered by score from low to high. Scores represent edit distance, so + * lower scores are better. + */ + score(queryString) { + let queryWords = queryString + .trim() + .split(/\s+/) + .map(word => word.toLocaleLowerCase()); + let minDistanceByDoc = this._traverse({ queryWords }); + let results = []; + for (let doc of this._documents) { + let distance = minDistanceByDoc.get(doc); + results.push({ + document: doc, + score: distance === undefined ? Infinity : distance, + }); + } + results.sort((a, b) => a.score - b.score); + return results; + } + + /** + * Builds the phrase tree based on the current documents. + * + * The phrase tree lets us efficiently match queries against phrases. Each + * path through the tree starting from the root and ending at a leaf + * represents a complete phrase in a document (or more than one document, if + * the same phrase is present in multiple documents). Each node in the path + * represents a word in the phrase. To match a query, we start at the root, + * and in the root we look up the query's first word. If the word matches the + * first word of any phrase, then the root will have a child node representing + * that word, and we move on to the child node. Then we look up the query's + * second word in the child node, and so on, until either a lookup fails or we + * reach a leaf node. + * + * @param {Node} node + * The current node being visited. + * @param {object} doc + * The document whose phrases are being added to the tree. + * @param {Array} phrase + * The phrase to add to the tree. + * @param {number} wordIndex + * The index in the phrase of the current word. + */ + _buildPhraseTree(node, doc, phrase, wordIndex) { + if (phrase.length == wordIndex) { + // We're done with this phrase. + return; + } + + let word = phrase[wordIndex].toLocaleLowerCase(); + let child = node.childrenByWord.get(word); + if (!child) { + child = new Node(word); + node.childrenByWord.set(word, child); + } + child.documents.add(doc); + + // Recurse with the next word in the phrase. + this._buildPhraseTree(child, doc, phrase, wordIndex + 1); + } + + /** + * Traverses a path in the phrase tree in order to score a query. See + * `_buildPhraseTree` for a description of how this works. + * + * @param {object} options + * Options. + * @param {Array} options.queryWords + * The query being scored, split into words. + * @param {Node} [options.node] + * The node currently being visited. + * @param {Map} [options.minDistanceByDoc] + * Keeps track of the minimum edit distance for each document as the + * traversal continues. + * @param {number} [options.queryWordsIndex] + * The current index in the query words array. + * @param {number} [options.phraseDistance] + * The total edit distance between the query and the path in the tree that's + * been traversed so far. + * @returns {Map} minDistanceByDoc + */ + _traverse({ + queryWords, + node = this._rootNode, + minDistanceByDoc = new Map(), + queryWordsIndex = 0, + phraseDistance = 0, + } = {}) { + if (!node.childrenByWord.size) { + // We reached a leaf node. The query has matched a phrase. If the query + // and the phrase have the same number of words, then queryWordsIndex == + // queryWords.length also. Otherwise the query contains more words than + // the phrase. We still count that as a match. + for (let doc of node.documents) { + minDistanceByDoc.set( + doc, + Math.min( + phraseDistance, + minDistanceByDoc.has(doc) ? minDistanceByDoc.get(doc) : Infinity + ) + ); + } + return minDistanceByDoc; + } + + if (queryWordsIndex == queryWords.length) { + // We exhausted all the words in the query but have not reached a leaf + // node. No match; the query has matched a phrase(s) up to this point, + // but it doesn't have enough words. + return minDistanceByDoc; + } + + // Compare each word in the node to the current query word. + let queryWord = queryWords[queryWordsIndex]; + for (let [childWord, child] of node.childrenByWord) { + let distance = lazy.NLP.levenshtein(queryWord, childWord); + if (distance <= this._distanceThreshold) { + // The word represented by this child node matches the current query + // word. Recurse into the child node. + this._traverse({ + node: child, + queryWords, + queryWordsIndex: queryWordsIndex + 1, + phraseDistance: phraseDistance + distance, + minDistanceByDoc, + }); + } + // Else, the path that continues at the child node can't possibly match + // the query, so don't recurse into it. + } + + return minDistanceByDoc; + } +} + +/** + * Gets appropriate values for each tip's payload. + * + * @param {string} tip a value from the TIPS enum + * @returns {object} Properties to include in the payload + */ +function getPayloadForTip(tip) { + const baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL"); + switch (tip) { + case TIPS.CLEAR: + return { + titleL10n: { id: "intervention-clear-data" }, + buttons: [{ l10n: { id: "intervention-clear-data-confirm" } }], + helpUrl: baseURL + "delete-browsing-search-download-history-firefox", + }; + case TIPS.REFRESH: + return { + titleL10n: { id: "intervention-refresh-profile" }, + buttons: [{ l10n: { id: "intervention-refresh-profile-confirm" } }], + helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings", + }; + case TIPS.UPDATE_ASK: + return { + titleL10n: { id: "intervention-update-ask" }, + buttons: [{ l10n: { id: "intervention-update-ask-confirm" } }], + helpUrl: baseURL + "update-firefox-latest-release", + }; + case TIPS.UPDATE_REFRESH: + return { + titleL10n: { id: "intervention-update-refresh" }, + buttons: [{ l10n: { id: "intervention-update-refresh-confirm" } }], + helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings", + }; + case TIPS.UPDATE_RESTART: + return { + titleL10n: { id: "intervention-update-restart" }, + buttons: [{ l10n: { id: "intervention-update-restart-confirm" } }], + helpUrl: baseURL + "update-firefox-latest-release", + }; + case TIPS.UPDATE_WEB: + return { + titleL10n: { id: "intervention-update-web" }, + buttons: [{ l10n: { id: "intervention-update-web-confirm" } }], + helpUrl: baseURL + "update-firefox-latest-release", + }; + default: + throw new Error("Unknown TIP type."); + } +} + +/** + * A provider that returns actionable tip results when the user is performing + * a search related to those actions. + */ +class ProviderInterventions extends UrlbarProvider { + constructor() { + super(); + // The tip we should currently show. + this.currentTip = TIPS.NONE; + + this.tipsShownInCurrentEngagement = new Set(); + + // This object is used to match the user's queries to tips. + XPCOMUtils.defineLazyGetter(this, "queryScorer", () => { + let queryScorer = new QueryScorer({ + variations: new Map([ + // Recognize "fire fox", "fox fire", and "foxfire" as "firefox". + ["firefox", ["fire fox", "fox fire", "foxfire"]], + // Recognize "mozila" as "mozilla". This will catch common mispellings + // "mozila", "mozzila", and "mozzilla" (among others) due to the edit + // distance threshold of 1. + ["mozilla", ["mozila"]], + ]), + }); + for (let [id, phrases] of Object.entries(DOCUMENTS)) { + queryScorer.addDocument({ id, phrases }); + } + return queryScorer; + }); + } + + /** + * Enum of the types of intervention tips. + * + * @returns {{ NONE: string; CLEAR: string; REFRESH: string; UPDATE_ASK: string; UPDATE_CHECKING: string; UPDATE_REFRESH: string; UPDATE_RESTART: string; UPDATE_WEB: string; }} + */ + get TIP_TYPE() { + return TIPS; + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * + * @returns {string} + */ + get name() { + return "UrlbarProviderInterventions"; + } + + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + if ( + !queryContext.searchString || + queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH || + lazy.UrlbarTokenizer.REGEXP_LIKE_PROTOCOL.test( + queryContext.searchString + ) || + !EN_LOCALE_MATCH.test(Services.locale.appLocaleAsBCP47) || + !Services.policies.isAllowed("urlbarinterventions") + ) { + return false; + } + + this.currentTip = TIPS.NONE; + + // Get the scores and the top score. + let docScores = this.queryScorer.score(queryContext.searchString); + let topDocScore = docScores[0]; + + // Multiple docs may have the top score, so collect them all. + let topDocIDs = new Set(); + if (topDocScore.score != Infinity) { + for (let { score, document } of docScores) { + if (score != topDocScore.score) { + break; + } + topDocIDs.add(document.id); + } + } + + // Determine the tip to show, if any. If there are multiple top-score docs, + // prefer them in the following order. + if (topDocIDs.has("update")) { + this._setCurrentTipFromAppUpdaterStatus(); + } else if (topDocIDs.has("clear")) { + let window = lazy.BrowserWindowTracker.getTopWindow(); + if (!lazy.PrivateBrowsingUtils.isWindowPrivate(window)) { + this.currentTip = TIPS.CLEAR; + } + } else if (topDocIDs.has("refresh")) { + // Note that the "update" case can set currentTip to TIPS.REFRESH too. + this.currentTip = TIPS.REFRESH; + } + + return ( + this.currentTip != TIPS.NONE && + (this.currentTip != TIPS.REFRESH || + Services.policies.isAllowed("profileRefresh")) + ); + } + + async _setCurrentTipFromAppUpdaterStatus(waitForCheck) { + // The update tips depend on the app's update status, so check for updates + // now (if we haven't already checked within the update-check period). If + // we're running in an xpcshell test, then checkForBrowserUpdate's attempt + // to use appUpdater will throw an exception because it won't be available. + // In that case, return false to disable the provider. + // + // This causes synchronous IO within the updater the first time it's called + // (at least) so be careful not to do it the first time the urlbar is used. + try { + this.checkForBrowserUpdate(); + } catch (ex) { + return; + } + + // There are several update tips. Figure out which one to show. + switch (lazy.appUpdater.status) { + case lazy.AppUpdater.STATUS.READY_FOR_RESTART: + // Prompt the user to restart. + this.currentTip = TIPS.UPDATE_RESTART; + break; + case lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL: + // There's an update available, but the user's pref says we should ask + // them to download and apply it. + this.currentTip = TIPS.UPDATE_ASK; + break; + case lazy.AppUpdater.STATUS.NO_UPDATES_FOUND: + // We show a special refresh tip when the browser is up to date. + this.currentTip = TIPS.UPDATE_REFRESH; + break; + case lazy.AppUpdater.STATUS.CHECKING: + // This will be the case the first time we check. See startQuery for + // how this special tip is handled. + this.currentTip = TIPS.UPDATE_CHECKING; + break; + case lazy.AppUpdater.STATUS.NO_UPDATER: + case lazy.AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY: + // If the updater is disabled at build time or at runtime, either by + // policy or because we're in a package, do not select any update tips. + this.currentTip = TIPS.NONE; + break; + default: + // Give up and ask the user to download the latest version from the + // web. We default to this case when the update is still downloading + // because an update doesn't actually occur if the user were to + // restart the browser. See bug 1625241. + this.currentTip = TIPS.UPDATE_WEB; + break; + } + } + + /** + * Starts querying. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + // TIPS.UPDATE_CHECKING is special, and we never actually show a tip that + // reflects a "checking" status. Instead it's handled like this. We call + // appUpdater.check() to start an update check. If we haven't called it + // before, then when it returns, appUpdater.status will be + // AppUpdater.STATUS.CHECKING, and it will remain CHECKING until the check + // finishes. We can add a listener to appUpdater to be notified when the + // check finishes. We don't want to wait for it to finish in isActive + // because that would block other providers from adding their results, so + // instead we wait here in startQuery. The results from other providers + // will be added while we're waiting. When the check finishes, we call + // addCallback and add our result. It doesn't matter how long the check + // takes because if another query starts, the view is closed, or the user + // changes the selection, the query will be canceled. + if (this.currentTip == TIPS.UPDATE_CHECKING) { + // First check the status because it may have changed between the time + // isActive was called and now. + this._setCurrentTipFromAppUpdaterStatus(); + if (this.currentTip == TIPS.UPDATE_CHECKING) { + // The updater is still checking, so wait for it to finish. + await new Promise(resolve => { + this._appUpdaterListener = () => { + lazy.appUpdater.removeListener(this._appUpdaterListener); + delete this._appUpdaterListener; + resolve(); + }; + lazy.appUpdater.addListener(this._appUpdaterListener); + }); + if (instance != this.queryInstance) { + // The query was canceled before the check finished. + return; + } + // Finally, set the tip from the updater status. The updater should no + // longer be checking, but guard against it just in case by returning + // early. + this._setCurrentTipFromAppUpdaterStatus(); + if (this.currentTip == TIPS.UPDATE_CHECKING) { + return; + } + } + } + // At this point, this.currentTip != TIPS.UPDATE_CHECKING because we + // returned early above if it was. + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + ...getPayloadForTip(this.currentTip), + type: this.currentTip, + icon: UrlbarUtils.ICON.TIP, + helpL10n: { + id: lazy.UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }, + } + ); + result.suggestedIndex = 1; + this.tipsShownInCurrentEngagement.add(this.currentTip); + addCallback(this, result); + } + + /** + * Cancels a running query, + * + * @param {UrlbarQueryContext} queryContext the query context object to cancel + * query for. + */ + cancelQuery(queryContext) { + // If we're waiting for appUpdater to finish its update check, + // this._appUpdaterListener will be defined. We can stop listening now. + if (this._appUpdaterListener) { + lazy.appUpdater.removeListener(this._appUpdaterListener); + delete this._appUpdaterListener; + } + } + + #pickResult(result, window) { + let tip = result.payload.type; + + // Do the tip action. + switch (tip) { + case TIPS.CLEAR: + openClearHistoryDialog(window); + break; + case TIPS.REFRESH: + case TIPS.UPDATE_REFRESH: + resetBrowser(window); + break; + case TIPS.UPDATE_ASK: + installBrowserUpdateAndRestart(); + break; + case TIPS.UPDATE_RESTART: + restartBrowser(); + break; + case TIPS.UPDATE_WEB: + window.gBrowser.selectedTab = window.gBrowser.addWebTab( + "https://www.mozilla.org/firefox/new/" + ); + break; + } + } + + onEngagement(isPrivate, state, queryContext, details, window) { + let { result } = details; + + // `selType` is "tip" when the tip's main button is picked. Ignore clicks on + // the help command ("tiphelp"), which is handled by UrlbarInput since we + // set `helpUrl` on the result payload. Currently there aren't any other + // buttons or commands but this will ignore clicks on them too. + if (result?.providerName == this.name && details.selType == "tip") { + this.#pickResult(result, window); + } + + if (["engagement", "abandonment"].includes(state)) { + for (let tip of this.tipsShownInCurrentEngagement) { + Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1); + } + } + this.tipsShownInCurrentEngagement.clear(); + } + + /** + * Checks for app updates. + * + * @param {boolean} force If false, this only checks for updates if we haven't + * already checked within the update-check period. If true, we check + * regardless. + */ + checkForBrowserUpdate(force = false) { + if ( + force || + !this._lastUpdateCheckTime || + Date.now() - this._lastUpdateCheckTime >= UPDATE_CHECK_PERIOD_MS + ) { + this._lastUpdateCheckTime = Date.now(); + lazy.appUpdater.check(); + } + } + + /** + * Resets the provider's app updater state by making a new app updater. This + * is intended to be used by tests. + */ + resetAppUpdater() { + // Reset only if the object has already been initialized. + if (!Object.getOwnPropertyDescriptor(lazy, "appUpdater").get) { + lazy.appUpdater = new lazy.AppUpdater(); + } + } +} + +export var UrlbarProviderInterventions = new ProviderInterventions(); + +/** + * Tip callbacks follow. + */ + +function installBrowserUpdateAndRestart() { + if (lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_AND_INSTALL) { + return Promise.resolve(); + } + return new Promise(resolve => { + let listener = () => { + // Once we call allowUpdateDownload, there are two possible end + // states: DOWNLOAD_FAILED and READY_FOR_RESTART. + if ( + lazy.appUpdater.status != lazy.AppUpdater.STATUS.READY_FOR_RESTART && + lazy.appUpdater.status != lazy.AppUpdater.STATUS.DOWNLOAD_FAILED + ) { + return; + } + lazy.appUpdater.removeListener(listener); + if (lazy.appUpdater.status == lazy.AppUpdater.STATUS.READY_FOR_RESTART) { + restartBrowser(); + } + resolve(); + }; + lazy.appUpdater.addListener(listener); + lazy.appUpdater.allowUpdateDownload(); + }); +} + +function openClearHistoryDialog(window) { + // The behaviour of the Clear Recent History dialog in PBM does + // not have the expected effect (bug 463607). + if (lazy.PrivateBrowsingUtils.isWindowPrivate(window)) { + return; + } + lazy.Sanitizer.showUI(window); +} + +function restartBrowser() { + // Notify all windows that an application quit has been requested. + let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance( + Ci.nsISupportsPRBool + ); + Services.obs.notifyObservers( + cancelQuit, + "quit-application-requested", + "restart" + ); + // Something aborted the quit process. + if (cancelQuit.data) { + return; + } + // If already in safe mode restart in safe mode. + if (Services.appinfo.inSafeMode) { + Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit); + } else { + Services.startup.quit( + Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart + ); + } +} + +function resetBrowser(window) { + if (!lazy.ResetProfile.resetSupported()) { + return; + } + lazy.ResetProfile.openConfirmationDialog(window); +} diff --git a/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs b/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs new file mode 100644 index 0000000000..00727c132d --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderOmnibox.sys.mjs @@ -0,0 +1,196 @@ +/* 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/. */ + +/** + * This module exports a provider class that is used for providers created by + * extensions using the `omnibox` API. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionSearchHandler: + "resource://gre/modules/ExtensionSearchHandler.sys.mjs", + + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +/** + * This provider handles results returned by extensions using the WebExtensions + * Omnibox API. If the user types a registered keyword, we send subsequent + * keystrokes to the extension. + */ +class ProviderOmnibox extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "Omnibox"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether the provider should be invoked for the given context. If this + * method returns false, the providers manager won't start a query with this + * provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {boolean} + * Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + if ( + queryContext.tokens[0] && + queryContext.tokens[0].value.length && + lazy.ExtensionSearchHandler.isKeywordRegistered( + queryContext.tokens[0].value + ) && + UrlbarUtils.substringAfter( + queryContext.searchString, + queryContext.tokens[0].value + ) + ) { + return true; + } + + // We need to handle cancellation here since isActive is called once per + // query but cancelQuery can be called multiple times per query. + // The frequent cancels can cause the extension's state to drift from the + // provider's state. + if (lazy.ExtensionSearchHandler.hasActiveInputSession()) { + lazy.ExtensionSearchHandler.handleInputCancelled(); + } + + return false; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {number} + * The provider's priority for the given query. + */ + getPriority(queryContext) { + return 0; + } + + /** + * This method is called by the providers manager when a query starts to fetch + * each extension provider's results. It fires the resultsRequested event. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @param {Function} addCallback + * The callback invoked by this method to add each result. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + // Fetch heuristic result. + let keyword = queryContext.tokens[0].value; + let description = lazy.ExtensionSearchHandler.getDescription(keyword); + let heuristicResult = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.ADDON, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [queryContext.searchString, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [queryContext.tokens[0].value, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.ICON.EXTENSION, + }) + ); + heuristicResult.heuristic = true; + addCallback(this, heuristicResult); + + // Fetch non-heuristic results. + let data = { + keyword, + text: queryContext.searchString, + inPrivateWindow: queryContext.isPrivate, + }; + this._resultsPromise = lazy.ExtensionSearchHandler.handleSearch( + data, + suggestions => { + if (instance != this.queryInstance) { + return; + } + for (let suggestion of suggestions) { + let content = `${queryContext.tokens[0].value} ${suggestion.content}`; + if (content == heuristicResult.payload.content) { + continue; + } + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.ADDON, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { + title: [suggestion.description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [content, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [ + queryContext.tokens[0].value, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest" }, + isBlockable: suggestion.deletable, + icon: UrlbarUtils.ICON.EXTENSION, + } + ) + ); + + addCallback(this, result); + } + } + ); + + // Since the extension has no way to signal when it's done pushing results, + // we add a timer racing with the addition. + let timeoutPromise = new SkippableTimer({ + name: "ProviderOmnibox", + time: lazy.UrlbarPrefs.get("extension.omnibox.timeout"), + logger: this.logger, + }).promise; + await Promise.race([timeoutPromise, this._resultsPromise]).catch(ex => + this.logger.error(ex) + ); + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result } = details; + if (result?.providerName != this.name) { + return; + } + + if (details.selType == "dismiss" && result.payload.isBlockable) { + lazy.ExtensionSearchHandler.handleInputDeleted(result.payload.title); + queryContext.view.controller.removeResult(result); + } + } +} + +export var UrlbarProviderOmnibox = new ProviderOmnibox(); diff --git a/browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs b/browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs new file mode 100644 index 0000000000..5994a295ed --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs @@ -0,0 +1,260 @@ +/* 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/. */ + +/** + * This module exports a provider, returning open tabs matches for the urlbar. + * It is also used to register and unregister open tabs. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +const PRIVATE_USER_CONTEXT_ID = -1; + +/** + * Class used to create the provider. + */ +export class UrlbarProviderOpenTabs extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "OpenTabs"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + // For now we don't actually use this provider to query open tabs, instead + // we join the temp table in UrlbarProviderPlaces. + return false; + } + + /** + * Tracks whether the memory tables have been initialized yet. Until this + * happens tabs are only stored in openTabs and later copied over to the + * memory table. + */ + static memoryTableInitialized = false; + + /** + * Maps the open tabs by userContextId. + */ + static _openTabs = new Map(); + + /** + * Return urls that is opening on given user context id. + * + * @param {integer} userContextId Containers user context id + * @param {boolean} isInPrivateWindow In private browsing window or not + * @returns {Array} urls + */ + static getOpenTabs(userContextId, isInPrivateWindow) { + userContextId = UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( + userContextId, + isInPrivateWindow + ); + return UrlbarProviderOpenTabs._openTabs.get(userContextId); + } + + /** + * Return userContextId that will be used in moz_openpages_temp table. + * + * @param {integer} userContextId Containers user context id + * @param {boolean} isInPrivateWindow In private browsing window or not + * @returns {interger} userContextId + */ + static getUserContextIdForOpenPagesTable(userContextId, isInPrivateWindow) { + return isInPrivateWindow ? PRIVATE_USER_CONTEXT_ID : userContextId; + } + + /** + * Copy over cached open tabs to the memory table once the Urlbar + * connection has been initialized. + */ + static promiseDBPopulated = + lazy.PlacesUtils.largeCacheDBConnDeferred.promise.then(async () => { + // Must be set before populating. + UrlbarProviderOpenTabs.memoryTableInitialized = true; + // Populate the table with the current cached tabs. + for (let [userContextId, urls] of UrlbarProviderOpenTabs._openTabs) { + for (let url of urls) { + await addToMemoryTable(url, userContextId).catch(console.error); + } + } + }); + + /** + * Registers a tab as open. + * + * @param {string} url Address of the tab + * @param {integer} userContextId Containers user context id + * @param {boolean} isInPrivateWindow In private browsing window or not + */ + static async registerOpenTab(url, userContextId, isInPrivateWindow) { + userContextId = UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( + userContextId, + isInPrivateWindow + ); + + if (!UrlbarProviderOpenTabs._openTabs.has(userContextId)) { + UrlbarProviderOpenTabs._openTabs.set(userContextId, []); + } + UrlbarProviderOpenTabs._openTabs.get(userContextId).push(url); + await addToMemoryTable(url, userContextId).catch(console.error); + } + + /** + * Unregisters a previously registered open tab. + * + * @param {string} url Address of the tab + * @param {integer} userContextId Containers user context id + * @param {boolean} isInPrivateWindow In private browsing window or not + */ + static async unregisterOpenTab(url, userContextId, isInPrivateWindow) { + userContextId = UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( + userContextId, + isInPrivateWindow + ); + + let openTabs = UrlbarProviderOpenTabs._openTabs.get(userContextId); + if (openTabs) { + let index = openTabs.indexOf(url); + if (index != -1) { + openTabs.splice(index, 1); + await removeFromMemoryTable(url, userContextId).catch(console.error); + } + } + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * match. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // Note: this is not actually expected to be used as an internal provider, + // because normal history search will already coalesce with the open tabs + // temp table to return proper frecency. + // TODO: + // * properly search and handle tokens, this is just a mock for now. + let instance = this.queryInstance; + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + await UrlbarProviderOpenTabs.promiseDBPopulated; + await conn.executeCached( + ` + SELECT url, userContextId + FROM moz_openpages_temp + `, + {}, + (row, cancel) => { + if (instance != this.queryInstance) { + cancel(); + return; + } + addCallback( + this, + new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { + url: row.getResultByName("url"), + userContextId: row.getResultByName("userContextId"), + } + ) + ); + } + ); + } +} + +/** + * Adds an open page to the memory table. + * + * @param {string} url Address of the page + * @param {number} userContextId Containers user context id + * @returns {Promise} resolved after the addition. + */ +async function addToMemoryTable(url, userContextId) { + if (!UrlbarProviderOpenTabs.memoryTableInitialized) { + return; + } + await lazy.UrlbarProvidersManager.runInCriticalSection(async () => { + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + await conn.executeCached( + ` + INSERT OR REPLACE INTO moz_openpages_temp (url, userContextId, open_count) + VALUES ( :url, + :userContextId, + IFNULL( ( SELECT open_count + 1 + FROM moz_openpages_temp + WHERE url = :url + AND userContextId = :userContextId ), + 1 + ) + ) + `, + { url, userContextId } + ); + }); +} + +/** + * Removes an open page from the memory table. + * + * @param {string} url Address of the page + * @param {number} userContextId Containers user context id + * @returns {Promise} resolved after the removal. + */ +async function removeFromMemoryTable(url, userContextId) { + if (!UrlbarProviderOpenTabs.memoryTableInitialized) { + return; + } + await lazy.UrlbarProvidersManager.runInCriticalSection(async () => { + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + await conn.executeCached( + ` + UPDATE moz_openpages_temp + SET open_count = open_count - 1 + WHERE url = :url + AND userContextId = :userContextId + `, + { url, userContextId } + ); + }); +} diff --git a/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs new file mode 100644 index 0000000000..0504d1ebe4 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderPlaces.sys.mjs @@ -0,0 +1,1536 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 expandtab + * 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/. */ +/* eslint complexity: ["error", 53] */ + +/** + * This module exports a provider that provides results from the Places + * database, including history, bookmarks, and open tabs. + */ +// Constants + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE_FILTERED = 0; + +// The default frecency value used when inserting matches with unknown frecency. +const FRECENCY_DEFAULT = 1000; + +// The result is notified on a delay, to avoid rebuilding the panel at every match. +const NOTIFYRESULT_DELAY_MS = 16; + +// Sqlite result row index constants. +const QUERYINDEX_QUERYTYPE = 0; +const QUERYINDEX_URL = 1; +const QUERYINDEX_TITLE = 2; +const QUERYINDEX_BOOKMARKED = 3; +const QUERYINDEX_BOOKMARKTITLE = 4; +const QUERYINDEX_TAGS = 5; +// QUERYINDEX_VISITCOUNT = 6; +// QUERYINDEX_TYPED = 7; +const QUERYINDEX_PLACEID = 8; +const QUERYINDEX_SWITCHTAB = 9; +const QUERYINDEX_FRECENCY = 10; + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (QUERYINDEX_BOOKMARKED) +// - the bookmark title, if it is a bookmark (QUERYINDEX_BOOKMARKTITLE) +// - the tags associated with a bookmarked entry (QUERYINDEX_TAGS) +const SQL_BOOKMARK_TAGS_FRAGMENT = `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked, + ( SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL + ORDER BY lastModified DESC LIMIT 1 + ) AS btitle, + ( SELECT GROUP_CONCAT(t.title, ', ') + FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent + WHERE b.fk = h.id + ) AS tags`; + +// TODO bug 412736: in case of a frecency tie, we might break it with h.typed +// and h.visit_count. That is slower though, so not doing it yet... +// NB: as a slight performance optimization, we only evaluate the "bookmarked" +// condition once, and avoid evaluating "btitle" and "tags" when it is false. +function defaultQuery(conditions = "") { + let query = `SELECT :query_type, h.url, h.title, ${SQL_BOOKMARK_TAGS_FRAGMENT}, + h.visit_count, h.typed, h.id, t.open_count, h.frecency + FROM moz_places h + LEFT JOIN moz_openpages_temp t + ON t.url = h.url + AND t.userContextId = :userContextId + WHERE h.frecency <> 0 + AND CASE WHEN bookmarked + THEN + AUTOCOMPLETE_MATCH(:searchString, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, + 1, t.open_count, + :matchBehavior, :searchBehavior, NULL) + ELSE + AUTOCOMPLETE_MATCH(:searchString, h.url, + h.title, '', + h.visit_count, h.typed, + 0, t.open_count, + :matchBehavior, :searchBehavior, NULL) + END + ${conditions ? "AND" : ""} ${conditions} + ORDER BY h.frecency DESC, h.id DESC + LIMIT :maxResults`; + return query; +} + +const SQL_SWITCHTAB_QUERY = `SELECT :query_type, t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, + t.open_count, NULL + FROM moz_openpages_temp t + LEFT JOIN moz_places h ON h.url_hash = hash(t.url) AND h.url = t.url + WHERE h.id IS NULL + AND t.userContextId = :userContextId + AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL, + NULL, NULL, NULL, t.open_count, + :matchBehavior, :searchBehavior, NULL) + ORDER BY t.ROWID DESC + LIMIT :maxResults`; + +// Getters + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +function setTimeout(callback, ms) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(callback, ms, timer.TYPE_ONE_SHOT); + return timer; +} + +// Maps restriction character types to textual behaviors. +XPCOMUtils.defineLazyGetter(lazy, "typeToBehaviorMap", () => { + return new Map([ + [lazy.UrlbarTokenizer.TYPE.RESTRICT_HISTORY, "history"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, "bookmark"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG, "tag"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, "openpage"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH, "search"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE, "title"], + [lazy.UrlbarTokenizer.TYPE.RESTRICT_URL, "url"], + ]); +}); + +XPCOMUtils.defineLazyGetter(lazy, "sourceToBehaviorMap", () => { + return new Map([ + [UrlbarUtils.RESULT_SOURCE.HISTORY, "history"], + [UrlbarUtils.RESULT_SOURCE.BOOKMARKS, "bookmark"], + [UrlbarUtils.RESULT_SOURCE.TABS, "openpage"], + [UrlbarUtils.RESULT_SOURCE.SEARCH, "search"], + ]); +}); + +// Helper functions + +/** + * Returns the key to be used for a match in a map for the purposes of removing + * duplicate entries - any 2 matches that should be considered the same should + * return the same key. The type of the returned key depends on the type of the + * match, so don't assume you can compare keys using ==. Instead, use + * ObjectUtils.deepEqual(). + * + * @param {object} match + * The match object. + * @returns {value} Some opaque key object. Use ObjectUtils.deepEqual() to + * compare keys. + */ +function makeKeyForMatch(match) { + let key, prefix; + let action = lazy.PlacesUtils.parseActionUrl(match.value); + if (!action) { + [key, prefix] = UrlbarUtils.stripPrefixAndTrim(match.value, { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimSlash: true, + trimEmptyQuery: true, + trimEmptyHash: true, + }); + return [key, prefix, null]; + } + + switch (action.type) { + case "searchengine": + // We want to exclude search suggestion matches that simply echo back the + // query string in the heuristic result. For example, if the user types + // "@engine test", we want to exclude a "test" suggestion match. + key = [ + action.type, + action.params.engineName, + ( + action.params.searchSuggestion || action.params.searchQuery + ).toLocaleLowerCase(), + ]; + break; + default: + [key, prefix] = UrlbarUtils.stripPrefixAndTrim( + action.params.url || match.value, + { + stripHttp: true, + stripHttps: true, + stripWww: true, + trimEmptyQuery: true, + trimSlash: true, + } + ); + break; + } + + return [key, prefix, action]; +} + +/** + * Makes a moz-action url for the given action and set of parameters. + * + * @param {string} type + * The action type. + * @param {object} params + * A JS object of action params. + * @returns {string} A moz-action url as a string. + */ +function makeActionUrl(type, params) { + let encodedParams = {}; + for (let key in params) { + // Strip null or undefined. + // Regardless, don't encode them or they would be converted to a string. + if (params[key] === null || params[key] === undefined) { + continue; + } + encodedParams[key] = encodeURIComponent(params[key]); + } + return `moz-action:${type},${JSON.stringify(encodedParams)}`; +} + +/** + * Converts an array of legacy match objects into UrlbarResults. + * Note that at every call we get the full set of results, included the + * previously returned ones, and new results may be inserted in the middle. + * This means we could sort these wrongly, the muxer should take care of it. + * + * @param {UrlbarQueryContext} context the query context. + * @param {Array} matches The match objects. + * @param {set} urls a Set containing all the found urls, used to discard + * already added results. + * @returns {Array} converted results + */ +function convertLegacyMatches(context, matches, urls) { + let results = []; + for (let match of matches) { + // First, let's check if we already added this result. + // `matches` always contains all of the results, includes ones + // we may have added already. This means we'll end up adding things in the + // wrong order here, but that's a task for the UrlbarMuxer. + let url = match.finalCompleteValue || match.value; + if (urls.has(url)) { + continue; + } + urls.add(url); + let result = makeUrlbarResult(context.tokens, { + url, + // `match.icon` is an empty string if there is no icon. Use undefined + // instead so that tests can be simplified by not including `icon: ""` in + // all their payloads. + icon: match.icon || undefined, + style: match.style, + comment: match.comment, + firstToken: context.tokens[0], + }); + // Should not happen, but better safe than sorry. + if (!result) { + continue; + } + + results.push(result); + } + return results; +} + +/** + * Creates a new UrlbarResult from the provided data. + * + * @param {Array} tokens the search tokens. + * @param {object} info includes properties from the legacy result. + * @returns {object} an UrlbarResult + */ +function makeUrlbarResult(tokens, info) { + let action = lazy.PlacesUtils.parseActionUrl(info.url); + if (action) { + switch (action.type) { + case "searchengine": + // Return a form history result. + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { + engine: action.params.engineName, + suggestion: [ + action.params.searchSuggestion, + UrlbarUtils.HIGHLIGHT.SUGGESTED, + ], + lowerCaseSuggestion: + action.params.searchSuggestion.toLocaleLowerCase(), + }) + ); + case "switchtab": + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + case "visiturl": + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + default: + console.error(`Unexpected action type: ${action.type}`); + return null; + } + } + + // This is a normal url/title tuple. + let source; + let tags = []; + let comment = info.comment; + + // The legacy autocomplete result may return "bookmark", "bookmark-tag" or + // "tag". In the last case it should not be considered a bookmark, but an + // history item with tags. We don't show tags for non bookmarked items though. + if (info.style.includes("bookmark")) { + source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; + } else { + source = UrlbarUtils.RESULT_SOURCE.HISTORY; + } + + // If the style indicates that the result is tagged, then the tags are + // included in the title, and we must extract them. + if (info.style.includes("tag")) { + [comment, tags] = info.comment.split(UrlbarUtils.TITLE_TAGS_SEPARATOR); + + // However, as mentioned above, we don't want to show tags for non- + // bookmarked items, so we include tags in the final result only if it's + // bookmarked, and we drop the tags otherwise. + if (source != UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + tags = ""; + } + + // Tags are separated by a comma and in a random order. + // We should also just include tags that match the searchString. + tags = tags + .split(",") + .map(t => t.trim()) + .filter(tag => { + let lowerCaseTag = tag.toLocaleLowerCase(); + return tokens.some(token => + lowerCaseTag.includes(token.lowerCaseValue) + ); + }) + .sort(); + } + + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [info.url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + title: [comment, UrlbarUtils.HIGHLIGHT.TYPED], + tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED], + }) + ); +} + +const MATCH_TYPE = { + HEURISTIC: "heuristic", + GENERAL: "general", + SUGGESTION: "suggestion", + EXTENSION: "extension", +}; + +/** + * Manages a single instance of a Places search. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + * @param {Function} listener + * Called as: `listener(matches, searchOngoing)` + * @param {PlacesProvider} provider + * The singleton that contains Places information + */ +function Search(queryContext, listener, provider) { + // We want to store the original string for case sensitive searches. + this._originalSearchString = queryContext.searchString; + this._trimmedOriginalSearchString = queryContext.trimmedSearchString; + let unescapedSearchString = UrlbarUtils.unEscapeURIForUI( + this._trimmedOriginalSearchString + ); + // We want to make sure "about:" is not stripped as a prefix so that the + // about pages provider will run and ultimately only suggest about pages when + // a user types "about:" into the address bar. + let prefix, suffix; + if (unescapedSearchString.startsWith("about:")) { + prefix = ""; + suffix = unescapedSearchString; + } else { + [prefix, suffix] = UrlbarUtils.stripURLPrefix(unescapedSearchString); + } + this._searchString = suffix; + this._strippedPrefix = prefix.toLowerCase(); + + this._matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY; + // Set the default behavior for this search. + this._behavior = this._searchString + ? lazy.UrlbarPrefs.get("defaultBehavior") + : this._emptySearchDefaultBehavior; + + this._inPrivateWindow = queryContext.isPrivate; + this._prohibitAutoFill = !queryContext.allowAutofill; + this._maxResults = queryContext.maxResults; + this._userContextId = queryContext.userContextId; + this._currentPage = queryContext.currentPage; + this._searchModeEngine = queryContext.searchMode?.engineName; + this._searchMode = queryContext.searchMode; + if (this._searchModeEngine) { + // Filter Places results on host. + let engine = Services.search.getEngineByName(this._searchModeEngine); + this._filterOnHost = engine.searchUrlDomain; + } + + this._userContextId = + lazy.UrlbarProviderOpenTabs.getUserContextIdForOpenPagesTable( + this._userContextId, + this._inPrivateWindow + ); + + // Use the original string here, not the stripped one, so the tokenizer can + // properly recognize token types. + let { tokens } = lazy.UrlbarTokenizer.tokenize({ + searchString: unescapedSearchString, + trimmedSearchString: unescapedSearchString.trim(), + }); + + // This allows to handle leading or trailing restriction characters specially. + this._leadingRestrictionToken = null; + if (tokens.length) { + if ( + lazy.UrlbarTokenizer.isRestrictionToken(tokens[0]) && + (tokens.length > 1 || + tokens[0].type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) + ) { + this._leadingRestrictionToken = tokens[0].value; + } + + // Check if the first token has a strippable prefix other than "about:" + // and remove it, but don't create an empty token. We preserve "about:" + // so that the about pages provider will run and ultimately only suggest + // about pages when a user types "about:" into the address bar. + if ( + prefix && + prefix != "about:" && + tokens[0].value.length > prefix.length + ) { + tokens[0].value = tokens[0].value.substring(prefix.length); + } + } + + // Eventually filter restriction tokens. In general it's a good idea, but if + // the consumer requested search mode, we should use the full string to avoid + // ignoring valid tokens. + this._searchTokens = + !queryContext || queryContext.restrictToken + ? this.filterTokens(tokens) + : tokens; + + // The behavior can be set through: + // 1. a specific restrictSource in the QueryContext + // 2. typed restriction tokens + if ( + queryContext && + queryContext.restrictSource && + lazy.sourceToBehaviorMap.has(queryContext.restrictSource) + ) { + this._behavior = 0; + this.setBehavior("restrict"); + let behavior = lazy.sourceToBehaviorMap.get(queryContext.restrictSource); + this.setBehavior(behavior); + + // When we are in restrict mode, all the tokens are valid for searching, so + // there is no _heuristicToken. + this._heuristicToken = null; + } else { + // The heuristic token is the first filtered search token, but only when it's + // actually the first thing in the search string. If a prefix or restriction + // character occurs first, then the heurstic token is null. We use the + // heuristic token to help determine the heuristic result. + let firstToken = !!this._searchTokens.length && this._searchTokens[0].value; + this._heuristicToken = + firstToken && this._trimmedOriginalSearchString.startsWith(firstToken) + ? firstToken + : null; + } + + // Set the right JavaScript behavior based on our preference. Note that the + // preference is whether or not we should filter JavaScript, and the + // behavior is if we should search it or not. + if (!lazy.UrlbarPrefs.get("filter.javascript")) { + this.setBehavior("javascript"); + } + + this._listener = listener; + this._provider = provider; + this._matches = []; + + // These are used to avoid adding duplicate entries to the results. + this._usedURLs = []; + this._usedPlaceIds = new Set(); + + // Counters for the number of results per MATCH_TYPE. + this._counts = Object.values(MATCH_TYPE).reduce((o, p) => { + o[p] = 0; + return o; + }, {}); +} + +Search.prototype = { + /** + * Enables the desired AutoComplete behavior. + * + * @param {string} type + * The behavior type to set. + */ + setBehavior(type) { + type = type.toUpperCase(); + this._behavior |= Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type]; + }, + + /** + * Determines if the specified AutoComplete behavior is set. + * + * @param {string} type + * The behavior type to test for. + * @returns {boolean} true if the behavior is set, false otherwise. + */ + hasBehavior(type) { + let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]; + return this._behavior & behavior; + }, + + /** + * Given an array of tokens, this function determines which query should be + * ran. It also removes any special search tokens. + * + * @param {Array} tokens + * An array of search tokens. + * @returns {Array} A new, filtered array of tokens. + */ + filterTokens(tokens) { + let foundToken = false; + // Set the proper behavior while filtering tokens. + let filtered = []; + for (let token of tokens) { + if (!lazy.UrlbarTokenizer.isRestrictionToken(token)) { + filtered.push(token); + continue; + } + let behavior = lazy.typeToBehaviorMap.get(token.type); + if (!behavior) { + throw new Error(`Unknown token type ${token.type}`); + } + // Don't use the suggest preferences if it is a token search and + // set the restrict bit to 1 (to intersect the search results). + if (!foundToken) { + foundToken = true; + // Do not take into account previous behavior (e.g.: history, bookmark) + this._behavior = 0; + this.setBehavior("restrict"); + } + this.setBehavior(behavior); + // We return tags only for bookmarks, thus when tags are enforced, we + // must also set the bookmark behavior. + if (behavior == "tag") { + this.setBehavior("bookmark"); + } + } + return filtered; + }, + + /** + * Stop this search. + * After invoking this method, we won't run any more searches or heuristics, + * and no new matches may be added to the current result. + */ + stop() { + // Avoid multiple calls or re-entrance. + if (!this.pending) { + return; + } + if (this._notifyTimer) { + this._notifyTimer.cancel(); + } + this._notifyDelaysCount = 0; + if (typeof this.interrupt == "function") { + this.interrupt(); + } + this.pending = false; + }, + + /** + * Whether this search is active. + */ + pending: true, + + /** + * Execute the search and populate results. + * + * @param {mozIStorageAsyncConnection} conn + * The Sqlite connection. + */ + async execute(conn) { + // A search might be canceled before it starts. + if (!this.pending) { + return; + } + + // Used by stop() to interrupt an eventual running statement. + this.interrupt = () => { + // Interrupt any ongoing statement to run the search sooner. + if (!lazy.UrlbarProvidersManager.interruptLevel) { + conn.interrupt(); + } + }; + + // For any given search, we run these queries: + // 1) open pages not supported by history (this._switchToTabQuery) + // 2) query based on match behavior + + // If the query is simply "@" and we have tokenAliasEngines then return + // early. UrlbarProviderTokenAliasEngines will add engine results. + let tokenAliasEngines = await lazy.UrlbarSearchUtils.tokenAliasEngines(); + if (this._trimmedOriginalSearchString == "@" && tokenAliasEngines.length) { + this._provider.finishSearch(true); + return; + } + + // Check if the first token is an action. If it is, we should set a flag + // so we don't include it in our searches. + this._firstTokenIsKeyword = + this._firstTokenIsKeyword || (await this._checkIfFirstTokenIsKeyword()); + if (!this.pending) { + return; + } + + if (this._trimmedOriginalSearchString) { + // If the user typed the search restriction char or we're in + // search-restriction mode, then we're done. + // UrlbarProviderSearchSuggestions will handle suggestions, if any. + let emptySearchRestriction = + this._trimmedOriginalSearchString.length <= 3 && + this._leadingRestrictionToken == lazy.UrlbarTokenizer.RESTRICT.SEARCH && + /\s*\S?$/.test(this._trimmedOriginalSearchString); + if ( + emptySearchRestriction || + (tokenAliasEngines.length && + this._trimmedOriginalSearchString.startsWith("@")) || + (this.hasBehavior("search") && this.hasBehavior("restrict")) + ) { + this._provider.finishSearch(true); + return; + } + } + + // Run our standard Places query. + let queries = []; + // "openpage" behavior is supported by the default query. + // _switchToTabQuery instead returns only pages not supported by history. + if (this.hasBehavior("openpage")) { + queries.push(this._switchToTabQuery); + } + queries.push(this._searchQuery); + for (let [query, params] of queries) { + await conn.executeCached(query, params, this._onResultRow.bind(this)); + if (!this.pending) { + return; + } + } + + // If we do not have enough matches search again with MATCH_ANYWHERE, to + // get more matches. + let count = this._counts[MATCH_TYPE.GENERAL]; + if (count < this._maxResults) { + this._matchBehavior = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; + queries = [this._searchQuery]; + if (this.hasBehavior("openpage")) { + queries.unshift(this._switchToTabQuery); + } + for (let [query, params] of queries) { + await conn.executeCached(query, params, this._onResultRow.bind(this)); + if (!this.pending) { + return; + } + } + } + }, + + async _checkIfFirstTokenIsKeyword() { + if (!this._heuristicToken) { + return false; + } + + let aliasEngine = await lazy.UrlbarSearchUtils.engineForAlias( + this._heuristicToken, + this._originalSearchString + ); + + if (aliasEngine) { + return true; + } + + let { entry } = await lazy.KeywordUtils.getBindableKeyword( + this._heuristicToken, + this._originalSearchString + ); + if (entry) { + this._filterOnHost = entry.url.host; + return true; + } + + return false; + }, + + _onResultRow(row, cancel) { + let queryType = row.getResultByIndex(QUERYINDEX_QUERYTYPE); + switch (queryType) { + case QUERYTYPE_FILTERED: + this._addFilteredQueryMatch(row); + break; + } + // If the search has been canceled by the user or by _addMatch, or we + // fetched enough results, we can stop the underlying Sqlite query. + let count = this._counts[MATCH_TYPE.GENERAL]; + if (!this.pending || count >= this._maxResults) { + cancel(); + } + }, + + /** + * Maybe restyle a SERP in history as a search-type result. To do this, + * we extract the search term from the SERP in history then generate a search + * URL with that search term. We restyle the SERP in history if its query + * parameters are a subset of those of the generated SERP. We check for a + * subset instead of exact equivalence since the generated URL may contain + * attribution parameters while a SERP in history from an organic search would + * not. We don't allow extra params in the history URL since they might + * indicate the search is not a first-page web SERP (as opposed to a image or + * other non-web SERP). + * + * Note: We will mistakenly dedupe SERPs for engines that have the same + * hostname as another engine. One example is if the user installed a + * Google Image Search engine. That engine's search URLs might only be + * distinguished by query params from search URLs from the default Google + * engine. + * + * @param {object} match + * The match to maybe restyle. + * @returns {boolean} True if the match can be restyled, false otherwise. + */ + _maybeRestyleSearchMatch(match) { + // Return if the URL does not represent a search result. + let historyUrl = match.value; + let parseResult = Services.search.parseSubmissionURL(historyUrl); + if (!parseResult?.engine) { + return false; + } + + // Here we check that the user typed all or part of the search string in the + // search history result. + let terms = parseResult.terms.toLowerCase(); + if ( + this._searchTokens.length && + this._searchTokens.every(token => !terms.includes(token.value)) + ) { + return false; + } + + // The URL for the search suggestion formed by the user's typed query. + let [generatedSuggestionUrl] = UrlbarUtils.getSearchQueryUrl( + parseResult.engine, + this._searchTokens.map(t => t.value).join(" ") + ); + + // We ignore termsParameterName when checking for a subset because we + // already checked that the typed query is a subset of the search history + // query above with this._searchTokens.every(...). + if ( + !lazy.UrlbarSearchUtils.serpsAreEquivalent( + historyUrl, + generatedSuggestionUrl, + [parseResult.termsParameterName] + ) + ) { + return false; + } + + // Turn the match into a searchengine action with a favicon. + match.value = makeActionUrl("searchengine", { + engineName: parseResult.engine.name, + input: parseResult.terms, + searchSuggestion: parseResult.terms, + searchQuery: parseResult.terms, + isSearchHistory: true, + }); + match.comment = parseResult.engine.name; + match.icon = match.icon || match.iconUrl; + match.style = "action searchengine favicon suggestion"; + return true; + }, + + _addMatch(match) { + if (typeof match.frecency != "number") { + throw new Error("Frecency not provided"); + } + + if (typeof match.type != "string") { + match.type = MATCH_TYPE.GENERAL; + } + + // A search could be canceled between a query start and its completion, + // in such a case ensure we won't notify any result for it. + if (!this.pending) { + return; + } + + match.style = match.style || "favicon"; + + // Restyle past searches, unless they are bookmarks or special results. + if ( + match.style == "favicon" && + (lazy.UrlbarPrefs.get("restyleSearches") || this._searchModeEngine) + ) { + let restyled = this._maybeRestyleSearchMatch(match); + if ( + restyled && + lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") == 0 + ) { + // The user doesn't want search history. + return; + } + } + + match.icon = match.icon || ""; + match.finalCompleteValue = match.finalCompleteValue || ""; + + let { index, replace } = this._getInsertIndexForMatch(match); + if (index == -1) { + return; + } + if (replace) { + // Replacing an existing match from the previous search. + this._matches.splice(index, 1); + } + this._matches.splice(index, 0, match); + this._counts[match.type]++; + + this.notifyResult(true); + }, + + /** + * @typedef {object} MatchPositionInformation + * @property {number} index + * The index the match should take in the results. Return -1 if the match + * should be discarded. + * @property {boolean} replace + * True if the match should replace the result already at + * matchPosition.index. + */ + + /** + * Check for duplicates and either discard the duplicate or replace the + * original match, in case the new one is more specific. For example, + * a Remote Tab wins over History, and a Switch to Tab wins over a Remote Tab. + * We must check both id and url for duplication, because keywords may change + * the url by replacing the %s placeholder. + * + * @param {object} match + * The match to insert. + * @returns {MatchPositionInformation} + */ + _getInsertIndexForMatch(match) { + let [urlMapKey, prefix, action] = makeKeyForMatch(match); + if ( + (match.placeId && this._usedPlaceIds.has(match.placeId)) || + this._usedURLs.some(e => lazy.ObjectUtils.deepEqual(e.key, urlMapKey)) + ) { + let isDupe = true; + if (action && ["switchtab", "remotetab"].includes(action.type)) { + // The new entry is a switch/remote tab entry, look for the duplicate + // among current matches. + for (let i = 0; i < this._usedURLs.length; ++i) { + let { key: matchKey, action: matchAction } = this._usedURLs[i]; + if (lazy.ObjectUtils.deepEqual(matchKey, urlMapKey)) { + isDupe = true; + if (!matchAction || action.type == "switchtab") { + this._usedURLs[i] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment, + }; + return { index: i, replace: true }; + } + break; // Found the duplicate, no reason to continue. + } + } + } else { + // Dedupe with this flow: + // 1. If the two URLs are the same, dedupe the newer one. + // 2. If they both contain www. or both do not contain it, prefer https. + // 3. If they differ by www., send both results to the Muxer and allow + // it to decide based on results from other providers. + let prefixRank = UrlbarUtils.getPrefixRank(prefix); + for (let i = 0; i < this._usedURLs.length; ++i) { + if (!this._usedURLs[i]) { + // This is true when the result at [i] is a searchengine result. + continue; + } + + let { key: existingKey, prefix: existingPrefix } = this._usedURLs[i]; + + let existingPrefixRank = UrlbarUtils.getPrefixRank(existingPrefix); + if (lazy.ObjectUtils.deepEqual(existingKey, urlMapKey)) { + isDupe = true; + + if (prefix == existingPrefix) { + // The URLs are identical. Throw out the new result. + break; + } + + if (prefix.endsWith("www.") == existingPrefix.endsWith("www.")) { + // The results differ only by protocol. + if (prefixRank <= existingPrefixRank) { + break; // Replace match. + } else { + this._usedURLs[i] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment, + }; + return { index: i, replace: true }; + } + } else { + // We have two identical URLs that differ only by www. We need to + // be sure what the heuristic result is before deciding how we + // should dedupe. We mark these as non-duplicates and let the + // muxer handle it. + isDupe = false; + continue; + } + } + } + } + + // Discard the duplicate. + if (isDupe) { + return { index: -1, replace: false }; + } + } + + // Add this to our internal tracker to ensure duplicates do not end up in + // the result. + // Not all entries have a place id, thus we fallback to the url for them. + // We cannot use only the url since keywords entries are modified to + // include the search string, and would be returned multiple times. Ids + // are faster too. + if (match.placeId) { + this._usedPlaceIds.add(match.placeId); + } + + let index = 0; + if (!this._groups) { + this._groups = []; + this._makeGroups(lazy.UrlbarPrefs.resultGroups, this._maxResults); + } + + let replace = 0; + for (let group of this._groups) { + // Move to the next group if the match type is incompatible, or if there + // is no available space or if the frecency is below the threshold. + if (match.type != group.type || !group.available) { + index += group.count; + continue; + } + + index += group.insertIndex; + group.available--; + if (group.insertIndex < group.count) { + replace = true; + } else { + group.count++; + } + group.insertIndex++; + break; + } + this._usedURLs[index] = { + key: urlMapKey, + action, + type: match.type, + prefix, + comment: match.comment || "", + }; + return { index, replace }; + }, + + _makeGroups(resultGroup, maxResultCount) { + if (!resultGroup.children) { + let type; + switch (resultGroup.group) { + case UrlbarUtils.RESULT_GROUP.FORM_HISTORY: + case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION: + case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION: + type = MATCH_TYPE.SUGGESTION; + break; + case UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE: + type = MATCH_TYPE.HEURISTIC; + break; + case UrlbarUtils.RESULT_GROUP.OMNIBOX: + type = MATCH_TYPE.EXTENSION; + break; + default: + type = MATCH_TYPE.GENERAL; + break; + } + if (this._groups.length) { + let last = this._groups[this._groups.length - 1]; + if (last.type == type) { + return; + } + } + // - `available` is the number of available slots in the group + // - `insertIndex` is the index of the first available slot in the group + // - `count` is the number of matches in the group, note that it also + // accounts for matches from the previous search, while `available` and + // `insertIndex` don't. + this._groups.push({ + type, + available: maxResultCount, + insertIndex: 0, + count: 0, + }); + return; + } + + let initialMaxResultCount; + if (typeof resultGroup.maxResultCount == "number") { + initialMaxResultCount = resultGroup.maxResultCount; + } else if (typeof resultGroup.availableSpan == "number") { + initialMaxResultCount = resultGroup.availableSpan; + } else { + initialMaxResultCount = this._maxResults; + } + let childMaxResultCount = Math.min(initialMaxResultCount, maxResultCount); + for (let child of resultGroup.children) { + this._makeGroups(child, childMaxResultCount); + } + }, + + _addFilteredQueryMatch(row) { + let placeId = row.getResultByIndex(QUERYINDEX_PLACEID); + let url = row.getResultByIndex(QUERYINDEX_URL); + let openPageCount = row.getResultByIndex(QUERYINDEX_SWITCHTAB) || 0; + let historyTitle = row.getResultByIndex(QUERYINDEX_TITLE) || ""; + let bookmarked = row.getResultByIndex(QUERYINDEX_BOOKMARKED); + let bookmarkTitle = bookmarked + ? row.getResultByIndex(QUERYINDEX_BOOKMARKTITLE) + : null; + let tags = row.getResultByIndex(QUERYINDEX_TAGS) || ""; + let frecency = row.getResultByIndex(QUERYINDEX_FRECENCY); + + let match = { + placeId, + value: url, + comment: bookmarkTitle || historyTitle, + icon: UrlbarUtils.getIconForUrl(url), + frecency: frecency || FRECENCY_DEFAULT, + }; + + if (openPageCount > 0 && this.hasBehavior("openpage")) { + if (this._currentPage == match.value) { + // Don't suggest switching to the current tab. + return; + } + // Actions are enabled and the page is open. Add a switch-to-tab result. + match.value = makeActionUrl("switchtab", { url: match.value }); + match.style = "action switchtab"; + } else if ( + this.hasBehavior("history") && + !this.hasBehavior("bookmark") && + !tags + ) { + // The consumer wants only history and not bookmarks and there are no + // tags. We'll act as if the page is not bookmarked. + match.style = "favicon"; + } else if (tags) { + // Store the tags in the title. It's up to the consumer to extract them. + match.comment += UrlbarUtils.TITLE_TAGS_SEPARATOR + tags; + // If we're not suggesting bookmarks, then this shouldn't display as one. + match.style = this.hasBehavior("bookmark") ? "bookmark-tag" : "tag"; + } else if (bookmarked) { + match.style = "bookmark"; + } + + this._addMatch(match); + }, + + /** + * @returns {string} + * A string consisting of the search query to be used based on the previously + * set urlbar suggestion preferences. + */ + get _suggestionPrefQuery() { + let conditions = []; + if (this._filterOnHost) { + conditions.push("h.rev_host = get_unreversed_host(:host || '.') || '.'"); + // When filtering on a host we are in some sort of site specific search, + // thus we want a cleaner set of results, compared to a general search. + // This means removing less interesting urls, like redirects or + // non-bookmarked title-less pages. + + if (lazy.UrlbarPrefs.get("restyleSearches") || this._searchModeEngine) { + // If restyle is enabled, we want to filter out redirect targets, + // because sources are urls built using search engines definitions that + // we can reverse-parse. + // In this case we can't filter on title-less pages because redirect + // sources likely don't have a title and recognizing sources is costly. + // Bug 468710 may help with this. + conditions.push(`NOT EXISTS ( + WITH visits(type) AS ( + SELECT visit_type + FROM moz_historyvisits + WHERE place_id = h.id + ORDER BY visit_date DESC + LIMIT 10 /* limit to the last 10 visits */ + ) + SELECT 1 FROM visits + WHERE type IN (5,6) + )`); + } else { + // If instead restyle is disabled, we want to keep redirect targets, + // because sources are often unreadable title-less urls. + conditions.push(`NOT EXISTS ( + WITH visits(id) AS ( + SELECT id + FROM moz_historyvisits + WHERE place_id = h.id + ORDER BY visit_date DESC + LIMIT 10 /* limit to the last 10 visits */ + ) + SELECT 1 + FROM visits src + JOIN moz_historyvisits dest ON src.id = dest.from_visit + WHERE dest.visit_type IN (5,6) + )`); + // Filter out empty-titled pages, they could be redirect sources that + // we can't recognize anymore because their target was wrongly expired + // due to Bug 1664252. + conditions.push("(h.foreign_count > 0 OR h.title NOTNULL)"); + } + } + + if ( + this.hasBehavior("restrict") || + (!this.hasBehavior("openpage") && + (!this.hasBehavior("history") || !this.hasBehavior("bookmark"))) + ) { + if (this.hasBehavior("history")) { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have up-to-date information yet. + conditions.push("+h.visit_count > 0"); + } + if (this.hasBehavior("bookmark")) { + conditions.push("bookmarked"); + } + if (this.hasBehavior("tag")) { + conditions.push("tags NOTNULL"); + } + } + + return defaultQuery(conditions.join(" AND ")); + }, + + get _emptySearchDefaultBehavior() { + // Further restrictions to apply for "empty searches" (searching for + // ""). The empty behavior is typed history, if history is enabled. + // Otherwise, it is bookmarks, if they are enabled. If both history and + // bookmarks are disabled, it defaults to open pages. + let val = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT; + if (lazy.UrlbarPrefs.get("suggest.history")) { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY; + } else if (lazy.UrlbarPrefs.get("suggest.bookmark")) { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; + } else { + val |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE; + } + return val; + }, + + /** + * If the user-provided string starts with a keyword that gave a heuristic + * result, this will strip it. + * + * @returns {string} The filtered search string. + */ + get _keywordFilteredSearchString() { + let tokens = this._searchTokens.map(t => t.value); + if (this._firstTokenIsKeyword) { + tokens = tokens.slice(1); + } + return tokens.join(" "); + }, + + /** + * Obtains the search query to be used based on the previously set search + * preferences (accessed by this.hasBehavior). + * + * @returns {Array} + * An array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _searchQuery() { + let params = { + parent: lazy.PlacesUtils.tagsFolderId, + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._keywordFilteredSearchString, + userContextId: this._userContextId, + // Limit the query to the the maximum number of desired results. + // This way we can avoid doing more work than needed. + maxResults: this._maxResults, + }; + if (this._filterOnHost) { + params.host = this._filterOnHost; + } + return [this._suggestionPrefQuery, params]; + }, + + /** + * Obtains the query to search for switch-to-tab entries. + * + * @returns {Array} + * An array consisting of the correctly optimized query to search the + * database with and an object containing the params to bound. + */ + get _switchToTabQuery() { + return [ + SQL_SWITCHTAB_QUERY, + { + query_type: QUERYTYPE_FILTERED, + matchBehavior: this._matchBehavior, + searchBehavior: this._behavior, + // We only want to search the tokens that we are left with - not the + // original search string. + searchString: this._keywordFilteredSearchString, + userContextId: this._userContextId, + maxResults: this._maxResults, + }, + ]; + }, + + // The result is notified to the search listener on a timer, to chunk multiple + // match updates together and avoid rebuilding the popup at every new match. + _notifyTimer: null, + + /** + * Notifies the current result to the listener. + * + * @param searchOngoing + * Indicates whether the search result should be marked as ongoing. + */ + _notifyDelaysCount: 0, + notifyResult(searchOngoing) { + let notify = () => { + if (!this.pending) { + return; + } + this._notifyDelaysCount = 0; + this._listener(this._matches, searchOngoing); + if (!searchOngoing) { + // Break possible cycles. + this._listener = null; + this._provider = null; + this.stop(); + } + }; + if (this._notifyTimer) { + this._notifyTimer.cancel(); + } + // In the worst case, we may get evenly spaced matches that would end up + // delaying the UI by N_MATCHES * NOTIFYRESULT_DELAY_MS. Thus, we clamp the + // number of times we may delay matches. + if (this._notifyDelaysCount > 3) { + notify(); + } else { + this._notifyDelaysCount++; + this._notifyTimer = setTimeout(notify, NOTIFYRESULT_DELAY_MS); + } + }, +}; + +/** + * Class used to create the provider. + */ +class ProviderPlaces extends UrlbarProvider { + // Promise resolved when the database initialization has completed, or null + // if it has never been requested. + _promiseDatabase = null; + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "Places"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Gets a Sqlite database handle. + * + * @returns {Promise<OpenedConnection>} + * A connection to the Sqlite database handle (according to {@link Sqlite.sys.mjs}). + * @throws A javascript exception + */ + getDatabaseHandle() { + if (!this._promiseDatabase) { + this._promiseDatabase = (async () => { + let conn = await lazy.PlacesUtils.promiseLargeCacheDBConnection(); + + // We don't catch exceptions here as it is too late to block shutdown. + lazy.Sqlite.shutdown.addBlocker("UrlbarProviderPlaces closing", () => { + // Break a possible cycle through the + // previous result, the controller and + // ourselves. + this._currentSearch = null; + }); + + return conn; + })().catch(ex => { + dump("Couldn't get database handle: " + ex + "\n"); + this.logger.error(ex); + }); + } + return this._promiseDatabase; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + if ( + !queryContext.trimmedSearchString && + queryContext.searchMode?.engineName && + lazy.UrlbarPrefs.get("update2.emptySearchBehavior") < 2 + ) { + return false; + } + return true; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + let urls = new Set(); + this._startLegacyQuery(queryContext, matches => { + if (instance != this.queryInstance) { + return; + } + let results = convertLegacyMatches(queryContext, matches, urls); + for (let result of results) { + addCallback(this, result); + } + }); + return this._deferred.promise; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._currentSearch) { + this._currentSearch.stop(); + } + if (this._deferred) { + this._deferred.resolve(); + } + // Don't notify since we are canceling this search. This also means we + // won't fire onSearchComplete for this search. + this.finishSearch(); + } + + /** + * Properly cleans up when searching is completed. + * + * @param {boolean} [notify] + * Indicates if we should notify the AutoComplete listener about our + * results or not. Default false. + */ + finishSearch(notify = false) { + // Clear state now to avoid race conditions, see below. + let search = this._currentSearch; + if (!search) { + return; + } + this._lastLowResultsSearchSuggestion = + search._lastLowResultsSearchSuggestion; + + if (!notify || !search.pending) { + return; + } + + // There is a possible race condition here. + // When a search completes it calls finishSearch that notifies results + // here. When the controller gets the last result it fires + // onSearchComplete. + // If onSearchComplete immediately starts a new search it will set a new + // _currentSearch, and on return the execution will continue here, after + // notifyResult. + // Thus, ensure that notifyResult is the last call in this method, + // otherwise you might be touching the wrong search. + search.notifyResult(false); + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result } = details; + if (result?.providerName != this.name) { + return; + } + + if (details.selType == "dismiss") { + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.SEARCH: + // URL restyled as a search suggestion. Generate the URL and remove it + // from browsing history. + let { url } = UrlbarUtils.getUrlFromResult(result); + lazy.PlacesUtils.history.remove(url).catch(console.error); + queryContext.view.controller.removeResult(result); + break; + case UrlbarUtils.RESULT_TYPE.URL: + // Remove browsing history entries from Places. + lazy.PlacesUtils.history + .remove(result.payload.url) + .catch(console.error); + queryContext.view.controller.removeResult(result); + break; + } + } + } + + _startLegacyQuery(queryContext, callback) { + let deferred = lazy.PromiseUtils.defer(); + let listener = (matches, searchOngoing) => { + callback(matches); + if (!searchOngoing) { + deferred.resolve(); + } + }; + this._startSearch(queryContext.searchString, listener, queryContext); + this._deferred = deferred; + } + + _startSearch(searchString, listener, queryContext) { + // Stop the search in case the controller has not taken care of it. + if (this._currentSearch) { + this.cancelQuery(); + } + + let search = (this._currentSearch = new Search( + queryContext, + listener, + this + )); + this.getDatabaseHandle() + .then(conn => search.execute(conn)) + .catch(ex => { + dump(`Query failed: ${ex}\n`); + this.logger.error(ex); + }) + .then(() => { + if (search == this._currentSearch) { + this.finishSearch(true); + } + }); + } +} + +export var UrlbarProviderPlaces = new ProviderPlaces(); diff --git a/browser/components/urlbar/UrlbarProviderPreloadedSites.sys.mjs b/browser/components/urlbar/UrlbarProviderPreloadedSites.sys.mjs new file mode 100644 index 0000000000..bc3d77cc71 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderPreloadedSites.sys.mjs @@ -0,0 +1,297 @@ +/* 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/. */ + +/** + * This module exports a provider that provides preloaded site results. These + * are intended to populate address bar results when the user has no history. + * They can be both autofilled and provided as regular results. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +const MS_PER_DAY = 86400000; // 24 * 60 * 60 * 1000 + +function PreloadedSite(url, title) { + this.uri = Services.io.newURI(url); + this.title = title; + this._matchTitle = title.toLowerCase(); + this._hasWWW = this.uri.host.startsWith("www."); + this._hostWithoutWWW = this._hasWWW ? this.uri.host.slice(4) : this.uri.host; +} + +/** + * Storage object for Preloaded Sites. + * add(url, title): adds a site to storage + * populate(sites) : populates the storage with array of [url,title] + * sites[]: resulting array of sites (PreloadedSite objects) + */ +XPCOMUtils.defineLazyGetter(lazy, "PreloadedSiteStorage", () => + Object.seal({ + sites: [], + + add(url, title) { + let site = new PreloadedSite(url, title); + this.sites.push(site); + }, + + populate(sites) { + this.sites = []; + for (let site of sites) { + this.add(site[0], site[1]); + } + }, + }) +); + +XPCOMUtils.defineLazyGetter(lazy, "ProfileAgeCreatedPromise", async () => { + let times = await lazy.ProfileAge(); + return times.created; +}); + +/** + * Class used to create the provider. + */ +class ProviderPreloadedSites extends UrlbarProvider { + constructor() { + super(); + + if (lazy.UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + fetch("chrome://browser/content/urlbar/preloaded-top-urls.json") + .then(response => response.json()) + .then(sites => lazy.PreloadedSiteStorage.populate(sites)) + .catch(ex => this.logger.error(ex)); + } + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "PreloadedSites"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + async isActive(queryContext) { + let instance = this.queryInstance; + + // This is usually reset on canceling or completing the query, but since we + // query in isActive, it may not have been canceled by the previous call. + // It is an object with values { result: UrlbarResult, instance: Query }. + // See the documentation for _getAutofillData for more information. + this._autofillData = null; + + await this._checkPreloadedSitesExpiry(); + if (instance != this.queryInstance) { + return false; + } + + if (!lazy.UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + return false; + } + + if ( + !lazy.UrlbarPrefs.get("autoFill") || + !queryContext.allowAutofill || + queryContext.tokens.length != 1 + ) { + return false; + } + + // Trying to autofill an extremely long string would be expensive, and + // not particularly useful since the filled part falls out of screen anyway. + if (queryContext.searchString.length > UrlbarUtils.MAX_TEXT_LENGTH) { + return false; + } + + [this._strippedPrefix, this._searchString] = UrlbarUtils.stripURLPrefix( + queryContext.searchString + ); + if (!this._searchString || !this._searchString.length) { + return false; + } + this._lowerCaseSearchString = this._searchString.toLowerCase(); + this._strippedPrefix = this._strippedPrefix.toLowerCase(); + + // As an optimization, don't try to autofill if the search term includes any + // whitespace. + if (lazy.UrlbarTokenizer.REGEXP_SPACES.test(queryContext.searchString)) { + return false; + } + + // Fetch autofill result now, rather than in startQuery. We do this so the + // muxer doesn't have to wait on autofill for every query, since startQuery + // will be guaranteed to return a result very quickly using this approach. + // Bug 1651101 is filed to improve this behaviour. + let result = await this._getAutofillResult(queryContext); + if (instance != this.queryInstance) { + return false; + } + this._autofillData = { result, instance }; + return true; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // Check if the query was cancelled while the autofill result was being + // fetched. We don't expect this to be true since we also check the instance + // in isActive and clear _autofillData in cancelQuery, but we sanity check it. + if ( + this._autofillData.result && + this._autofillData.instance == this.queryInstance + ) { + this._autofillData.result.heuristic = true; + addCallback(this, this._autofillData.result); + this._autofillData = null; + } + + // Now, add non-autofill preloaded sites. + for (let site of lazy.PreloadedSiteStorage.sites) { + let url = site.uri.spec; + if ( + (!this._strippedPrefix || url.startsWith(this._strippedPrefix)) && + (site.uri.host.includes(this._lowerCaseSearchString) || + site._matchTitle.includes(this._lowerCaseSearchString)) + ) { + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [site.title, UrlbarUtils.HIGHLIGHT.TYPED], + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(url), + }) + ); + addCallback(this, result); + } + } + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._autofillData?.instance == this.queryInstance) { + this._autofillData = null; + } + } + + /** + * Populates the preloaded site cache with a list of sites. Intended for tests + * only. + * + * @param {Array} list + * An array of URLs mapped to titles. See preloaded-top-urls.json for + * the format. + */ + populatePreloadedSiteStorage(list) { + lazy.PreloadedSiteStorage.populate(list); + } + + async _getAutofillResult(queryContext) { + let matchedSite = lazy.PreloadedSiteStorage.sites.find(site => { + return ( + (!this._strippedPrefix || + site.uri.spec.startsWith(this._strippedPrefix)) && + (site.uri.host.startsWith(this._lowerCaseSearchString) || + site.uri.host.startsWith("www." + this._lowerCaseSearchString)) + ); + }); + if (!matchedSite) { + return null; + } + + let url = matchedSite.uri.spec; + + let [title] = UrlbarUtils.stripPrefixAndTrim(url, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + fallbackTitle: [title, UrlbarUtils.HIGHLIGHT.TYPED], + url: [url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: UrlbarUtils.getIconForUrl(url), + }) + ); + + let value = UrlbarUtils.stripURLPrefix(url)[1]; + value = + this._strippedPrefix + value.substr(value.indexOf(this._searchString)); + let autofilledValue = + queryContext.searchString + + value.substring(queryContext.searchString.length); + result.autofill = { + type: "preloaded", + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } + + async _checkPreloadedSitesExpiry() { + if (!lazy.UrlbarPrefs.get("usepreloadedtopurls.enabled")) { + return; + } + let profileCreationDate = await lazy.ProfileAgeCreatedPromise; + let daysSinceProfileCreation = + (Date.now() - profileCreationDate) / MS_PER_DAY; + if ( + daysSinceProfileCreation > + lazy.UrlbarPrefs.get("usepreloadedtopurls.expire_days") + ) { + Services.prefs.setBoolPref( + "browser.urlbar.usepreloadedtopurls.enabled", + false + ); + } + } +} + +export var UrlbarProviderPreloadedSites = new ProviderPreloadedSites(); diff --git a/browser/components/urlbar/UrlbarProviderPrivateSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderPrivateSearch.sys.mjs new file mode 100644 index 0000000000..51a7e33f47 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderPrivateSearch.sys.mjs @@ -0,0 +1,133 @@ +/* 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/. */ + +/** + * This module exports a provider returning a private search entry. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderPrivateSearch extends UrlbarProvider { + constructor() { + super(); + // Maps the open tabs by userContextId. + this.openTabs = new Map(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "PrivateSearch"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + lazy.UrlbarSearchUtils.separatePrivateDefaultUIEnabled && + !queryContext.isPrivate && + queryContext.tokens.length + ); + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * match. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + let searchString = queryContext.trimmedSearchString; + if ( + queryContext.tokens.some( + t => t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH + ) + ) { + if (queryContext.tokens.length == 1) { + // There's only the restriction token, bail out. + return; + } + // Remove the restriction char from the search string. + searchString = queryContext.tokens + .filter(t => t.type != lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) + .map(t => t.value) + .join(" "); + } + + let instance = this.queryInstance; + + let engine = queryContext.searchMode?.engineName + ? Services.search.getEngineByName(queryContext.searchMode.engineName) + : await Services.search.getDefaultPrivate(); + let isPrivateEngine = + lazy.UrlbarSearchUtils.separatePrivateDefault && + engine != (await Services.search.getDefault()); + this.logger.info(`isPrivateEngine: ${isPrivateEngine}`); + + // This is a delay added before returning results, to avoid flicker. + // Our result must appear only when all results are searches, but if search + // results arrive first, then the muxer would insert our result and then + // immediately remove it when non-search results arrive. + await new SkippableTimer({ + name: "ProviderPrivateSearch", + time: 100, + logger: this.logger, + }).promise; + + if (instance != this.queryInstance) { + return; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + query: [searchString, UrlbarUtils.HIGHLIGHT.NONE], + icon: engine.iconURI?.spec, + inPrivateWindow: true, + isPrivateEngine, + }) + ); + result.suggestedIndex = 1; + addCallback(this, result); + } +} + +export var UrlbarProviderPrivateSearch = new ProviderPrivateSearch(); diff --git a/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs new file mode 100644 index 0000000000..914dc983af --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderQuickActions.sys.mjs @@ -0,0 +1,372 @@ +/* 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 { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + QuickActionsLoaderDefault: + "resource:///modules/QuickActionsLoaderDefault.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +// These prefs are relative to the `browser.urlbar` branch. +const ENABLED_PREF = "quickactions.enabled"; +const SUGGEST_PREF = "suggest.quickactions"; +const MATCH_IN_PHRASE_PREF = "quickactions.matchInPhrase"; +const MIN_SEARCH_PREF = "quickactions.minimumSearchString"; +const DYNAMIC_TYPE_NAME = "quickactions"; + +// When the urlbar is first focused and no search term has been +// entered we show a limited number of results. +const ACTIONS_SHOWN_FOCUS = 4; + +// Default icon shown for actions if no custom one is provided. +const DEFAULT_ICON = "chrome://global/skin/icons/settings.svg"; + +// The suggestion index of the actions row within the urlbar results. +const SUGGESTED_INDEX = 1; + +/** + * A provider that returns a suggested url to the user based on what + * they have currently typed so they can navigate directly. + */ +class ProviderQuickActions extends UrlbarProvider { + constructor() { + super(); + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME); + Services.tm.idleDispatchToMainThread(() => + lazy.QuickActionsLoaderDefault.load() + ); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return DYNAMIC_TYPE_NAME; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + getPriority(context) { + if (!context.searchString) { + return 1; + } + return 0; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + lazy.UrlbarPrefs.get(ENABLED_PREF) && + ((lazy.UrlbarPrefs.get(SUGGEST_PREF) && !queryContext.searchMode) || + queryContext.searchMode?.source == UrlbarUtils.RESULT_SOURCE.ACTIONS) + ); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + let input = queryContext.trimmedSearchString.toLowerCase(); + + if ( + !queryContext.searchMode && + input.length < lazy.UrlbarPrefs.get(MIN_SEARCH_PREF) + ) { + return; + } + + let results = [...(this.#prefixes.get(input) ?? [])]; + + if (lazy.UrlbarPrefs.get(MATCH_IN_PHRASE_PREF)) { + for (let [keyword, key] of this.#keywords) { + if (input.includes(keyword)) { + results.push(key); + } + } + } + // Ensure results are unique. + results = [...new Set(results)]; + + // Remove invisible actions. + results = results.filter(key => { + const action = this.#actions.get(key); + return !action.isVisible || action.isVisible(); + }); + + if (!results?.length) { + return; + } + + // If all actions are inactive, don't show anything. + if ( + results.every(key => { + const action = this.#actions.get(key); + return action.isActive && !action.isActive(); + }) + ) { + return; + } + + // If we are in the Actions searchMode then we want to show all the actions + // but not when we are in the normal url mode on first focus. + if ( + results.length > ACTIONS_SHOWN_FOCUS && + !input && + !queryContext.searchMode + ) { + results.length = ACTIONS_SHOWN_FOCUS; + } + + const result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.ACTIONS, + { + results: results.map(key => ({ key })), + dynamicType: DYNAMIC_TYPE_NAME, + inputLength: input.length, + } + ); + result.suggestedIndex = SUGGESTED_INDEX; + addCallback(this, result); + this.#resultFromLastQuery = result; + } + + getViewTemplate(result) { + return { + children: [ + { + name: "buttons", + tag: "div", + children: result.payload.results.map(({ key }, i) => { + let action = this.#actions.get(key); + let inActive = "isActive" in action && !action.isActive(); + let row = { + name: `button-${i}`, + tag: "span", + attributes: { + "data-key": key, + "data-input-length": result.payload.inputLength, + class: "urlbarView-quickaction-row", + role: inActive ? "" : "button", + }, + children: [ + { + name: `icon-${i}`, + tag: "div", + attributes: { class: "urlbarView-favicon" }, + children: [ + { + name: `image-${i}`, + tag: "img", + attributes: { + class: "urlbarView-favicon-img", + src: action.icon || DEFAULT_ICON, + }, + }, + ], + }, + { + name: `label-${i}`, + tag: "span", + attributes: { class: "urlbarView-label" }, + }, + ], + }; + if (inActive) { + row.attributes.disabled = "disabled"; + } + return row; + }), + }, + ], + }; + } + + getViewUpdate(result) { + let viewUpdate = {}; + result.payload.results.forEach(({ key }, i) => { + let action = this.#actions.get(key); + viewUpdate[`label-${i}`] = { + l10n: { id: action.label, cacheable: true }, + }; + }); + return viewUpdate; + } + + #pickResult(result, itemPicked) { + let { key, inputLength } = itemPicked.dataset; + // We clamp the input length to limit the number of keys to + // the number of actions * 10. + inputLength = Math.min(inputLength, 10); + Services.telemetry.keyedScalarAdd( + `quickaction.picked`, + `${key}-${inputLength}`, + 1 + ); + let options = this.#actions.get(itemPicked.dataset.key).onPick() ?? {}; + if (options.focusContent) { + itemPicked.ownerGlobal.gBrowser.selectedBrowser.focus(); + } + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + */ + onEngagement(isPrivate, state, queryContext, details) { + // Ignore engagements on other results that didn't end the session. + if (details.result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + if (state == "engagement" && queryContext) { + // Get the result that's visible in the view. `details.result` is the + // engaged result, if any; if it's from this provider, then that's the + // visible result. Otherwise fall back to #getVisibleResultFromLastQuery. + let { result } = details; + if (result?.providerName != this.name) { + result = this.#getVisibleResultFromLastQuery(queryContext.view); + } + + result?.payload.results.forEach(({ key }) => { + Services.telemetry.keyedScalarAdd( + `quickaction.impression`, + `${key}-${queryContext.trimmedSearchString.length}`, + 1 + ); + }); + } + + // Handle picks. + if (details.result?.providerName == this.name) { + this.#pickResult(details.result, details.element); + } + + this.#resultFromLastQuery = null; + } + + /** + * Adds a new QuickAction. + * + * @param {string} key A key to identify this action. + * @param {string} definition An object that describes the action. + */ + addAction(key, definition) { + this.#actions.set(key, definition); + definition.commands.forEach(cmd => this.#keywords.set(cmd, key)); + this.#loopOverPrefixes(definition.commands, prefix => { + let result = this.#prefixes.get(prefix); + if (result) { + if (!result.includes(key)) { + result.push(key); + } + } else { + result = [key]; + } + this.#prefixes.set(prefix, result); + }); + } + + /** + * Removes an action. + * + * @param {string} key A key to identify this action. + */ + removeAction(key) { + let definition = this.#actions.get(key); + this.#actions.delete(key); + definition.commands.forEach(cmd => this.#keywords.delete(cmd)); + this.#loopOverPrefixes(definition.commands, prefix => { + let result = this.#prefixes.get(prefix); + if (result) { + result = result.filter(val => val != key); + } + this.#prefixes.set(prefix, result); + }); + } + + // A map from keywords to an action. + #keywords = new Map(); + + // A map of all prefixes to an array of actions. + #prefixes = new Map(); + + // The actions that have been added. + #actions = new Map(); + + // The result we added during the most recent query. + #resultFromLastQuery = null; + + #loopOverPrefixes(commands, fun) { + for (const command of commands) { + // Loop over all the prefixes of the word, ie + // "", "w", "wo", "wor", stopping just before the full + // word itself which will be matched by the whole + // phrase matching. + for (let i = 1; i <= command.length; i++) { + let prefix = command.substring(0, command.length - i); + fun(prefix); + } + } + } + + #getVisibleResultFromLastQuery(view) { + let result = this.#resultFromLastQuery; + + if ( + result?.rowIndex >= 0 && + view?.visibleResults?.[result.rowIndex] == result + ) { + // The result was visible. + return result; + } + + // Find a visible result. + return view?.visibleResults?.find(r => r.providerName == this.name); + } +} + +export var UrlbarProviderQuickActions = new ProviderQuickActions(); diff --git a/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs new file mode 100644 index 0000000000..2ba3a17d98 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs @@ -0,0 +1,869 @@ +/* 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 { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + MerinoClient: "resource:///modules/MerinoClient.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +const TELEMETRY_PREFIX = "contextual.services.quicksuggest"; + +const TELEMETRY_SCALARS = { + BLOCK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.block_dynamic_wikipedia`, + BLOCK_NONSPONSORED: `${TELEMETRY_PREFIX}.block_nonsponsored`, + BLOCK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_nonsponsored_bestmatch`, + BLOCK_SPONSORED: `${TELEMETRY_PREFIX}.block_sponsored`, + BLOCK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.block_sponsored_bestmatch`, + CLICK_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.click_dynamic_wikipedia`, + CLICK_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.click_nav_notmatched`, + CLICK_NAV_SHOWN_HEURISTIC: `${TELEMETRY_PREFIX}.click_nav_shown_heuristic`, + CLICK_NAV_SHOWN_NAV: `${TELEMETRY_PREFIX}.click_nav_shown_nav`, + CLICK_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.click_nav_superceded`, + CLICK_NONSPONSORED: `${TELEMETRY_PREFIX}.click_nonsponsored`, + CLICK_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_nonsponsored_bestmatch`, + CLICK_SPONSORED: `${TELEMETRY_PREFIX}.click_sponsored`, + CLICK_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.click_sponsored_bestmatch`, + HELP_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.help_dynamic_wikipedia`, + HELP_NONSPONSORED: `${TELEMETRY_PREFIX}.help_nonsponsored`, + HELP_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_nonsponsored_bestmatch`, + HELP_SPONSORED: `${TELEMETRY_PREFIX}.help_sponsored`, + HELP_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.help_sponsored_bestmatch`, + IMPRESSION_DYNAMIC_WIKIPEDIA: `${TELEMETRY_PREFIX}.impression_dynamic_wikipedia`, + IMPRESSION_NAV_NOTMATCHED: `${TELEMETRY_PREFIX}.impression_nav_notmatched`, + IMPRESSION_NAV_SHOWN: `${TELEMETRY_PREFIX}.impression_nav_shown`, + IMPRESSION_NAV_SUPERCEDED: `${TELEMETRY_PREFIX}.impression_nav_superceded`, + IMPRESSION_NONSPONSORED: `${TELEMETRY_PREFIX}.impression_nonsponsored`, + IMPRESSION_NONSPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_nonsponsored_bestmatch`, + IMPRESSION_SPONSORED: `${TELEMETRY_PREFIX}.impression_sponsored`, + IMPRESSION_SPONSORED_BEST_MATCH: `${TELEMETRY_PREFIX}.impression_sponsored_bestmatch`, +}; + +/** + * A provider that returns a suggested url to the user based on what + * they have currently typed so they can navigate directly. + */ +class ProviderQuickSuggest extends UrlbarProvider { + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "UrlbarProviderQuickSuggest"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.NETWORK; + } + + /** + * @returns {object} An object mapping from mnemonics to scalar names. + */ + get TELEMETRY_SCALARS() { + return { ...TELEMETRY_SCALARS }; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + this.#resultFromLastQuery = null; + + // If the sources don't include search or the user used a restriction + // character other than search, don't allow any suggestions. + if ( + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || + (queryContext.restrictSource && + queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) + ) { + return false; + } + + if ( + !lazy.UrlbarPrefs.get("quickSuggestEnabled") || + queryContext.isPrivate || + queryContext.searchMode + ) { + return false; + } + + // Trim only the start of the search string because a trailing space can + // affect the suggestions. + let trimmedSearchString = queryContext.searchString.trimStart(); + if (!trimmedSearchString) { + return false; + } + this._trimmedSearchString = trimmedSearchString; + + return true; + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + let searchString = this._trimmedSearchString; + + // There are two sources for quick suggest: remote settings and Merino. + let promises = []; + if (lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled")) { + promises.push(lazy.QuickSuggestRemoteSettings.query(searchString)); + } + if ( + lazy.UrlbarPrefs.get("merinoEnabled") && + lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled") && + queryContext.allowRemoteResults() + ) { + promises.push(this._fetchMerinoSuggestions(queryContext, searchString)); + } + + // Wait for both sources to finish before adding a suggestion. + let values = await Promise.all(promises); + if (instance != this.queryInstance) { + return; + } + + let suggestions = values.flat().sort((a, b) => b.score - a.score); + + // Add a result for the first suggestion that can be shown. + for (let suggestion of suggestions) { + let canAdd = await this._canAddSuggestion(suggestion); + if (instance != this.queryInstance) { + return; + } + + let result; + if ( + canAdd && + (result = await this.#makeResult(queryContext, suggestion)) + ) { + this.#resultFromLastQuery = result; + addCallback(this, result); + return; + } + } + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + */ + onEngagement(isPrivate, state, queryContext, details) { + // Ignore engagements on other results that didn't end the session. + if (details.result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + // Reset the Merino session ID when a session ends. By design for the user's + // privacy, we don't keep it around between engagements. + if (state != "start" && !details.isSessionOngoing) { + this.#merino?.resetSession(); + } + + // Impression and clicked telemetry are both recorded on engagement. We + // define "impression" to mean a quick suggest result was present in the + // view when any result was picked. + if (state == "engagement" && queryContext) { + // Get the result that's visible in the view. `details.result` is the + // engaged result, if any; if it's from this provider, then that's the + // visible result. Otherwise fall back to #getVisibleResultFromLastQuery. + let { result } = details; + if (result?.providerName != this.name) { + result = this.#getVisibleResultFromLastQuery(queryContext.view); + } + + this.#recordEngagement(queryContext, isPrivate, result, details); + } + + if (details.result?.providerName == this.name) { + if (details.result.payload.dynamicType === "addons") { + lazy.QuickSuggest.getFeature("AddonSuggestions").handlePossibleCommand( + queryContext, + details.result, + details.selType + ); + } else if (details.selType == "dismiss") { + // Handle dismissals. + this.#dismissResult(queryContext, details.result); + } + } + + this.#resultFromLastQuery = null; + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result) { + // For now, we support only addons suggestion. + return lazy.QuickSuggest.getFeature("AddonSuggestions").getViewUpdate( + result + ); + } + + getResultCommands(result) { + if (result.payload.dynamicType === "addons") { + return lazy.QuickSuggest.getFeature("AddonSuggestions").getResultCommands( + result + ); + } + + return null; + } + + async #makeResult(queryContext, suggestion) { + let result; + switch (suggestion.provider) { + case "amo": + case "AddonSuggestions": + result = await lazy.QuickSuggest.getFeature( + "AddonSuggestions" + ).makeResult(queryContext, suggestion, this._trimmedSearchString); + break; + case "adm": // Merino + case "AdmWikipedia": // remote settings + result = await lazy.QuickSuggest.getFeature("AdmWikipedia").makeResult( + queryContext, + suggestion, + this._trimmedSearchString + ); + break; + default: + result = this.#makeDefaultResult(queryContext, suggestion); + break; + } + + if (!result) { + // Feature might return null, if the feature is disabled and so on. + return null; + } + + if (!result.hasSuggestedIndex) { + // When `bestMatchEnabled` is true, a "Top pick" checkbox appears in + // about:preferences. Show top pick suggestions as top picks only if that + // checkbox is checked. `suggest.bestMatch` is the corresponding pref. If + // `bestMatchEnabled` is false, the top pick feature is disabled, so show + // the suggestion as a usual Suggest result. + if ( + suggestion.is_top_pick && + lazy.UrlbarPrefs.get("bestMatchEnabled") && + lazy.UrlbarPrefs.get("suggest.bestmatch") + ) { + result.isBestMatch = true; + result.suggestedIndex = 1; + } else if ( + !isNaN(suggestion.position) && + lazy.UrlbarPrefs.get("quickSuggestAllowPositionInSuggestions") + ) { + result.suggestedIndex = suggestion.position; + } else { + result.isSuggestedIndexRelativeToGroup = true; + result.suggestedIndex = lazy.UrlbarPrefs.get( + suggestion.is_sponsored + ? "quickSuggestSponsoredIndex" + : "quickSuggestNonSponsoredIndex" + ); + } + } + + return result; + } + + #makeDefaultResult(queryContext, suggestion) { + let payload = { + url: suggestion.url, + icon: suggestion.icon, + isSponsored: suggestion.is_sponsored, + source: suggestion.source, + telemetryType: suggestion.provider, + helpUrl: lazy.QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }; + + if (suggestion.full_keyword) { + payload.title = suggestion.title; + payload.qsSuggestion = [ + suggestion.full_keyword, + UrlbarUtils.HIGHLIGHT.SUGGESTED, + ]; + } else { + payload.title = [suggestion.title, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + } + + #getVisibleResultFromLastQuery(view) { + let result = this.#resultFromLastQuery; + + if ( + result?.rowIndex >= 0 && + view?.visibleResults?.[result.rowIndex] == result + ) { + // The result was visible. + return result; + } + + // Find a visible result. Quick suggest results typically appear last in the + // view, so do a reverse search. + return view?.visibleResults?.findLast(r => r.providerName == this.name); + } + + #dismissResult(queryContext, result) { + if (!result.payload.isBlockable) { + this.logger.info("Dismissals disabled, ignoring dismissal"); + return; + } + + this.logger.info("Dismissing result: " + JSON.stringify(result)); + lazy.QuickSuggest.blockedSuggestions.add( + // adM results have `originalUrl`, which contains timestamp templates. + result.payload.originalUrl ?? result.payload.url + ); + queryContext.view.controller.removeResult(result); + } + + /** + * Records engagement telemetry. This should be called only at the end of an + * engagement when a quick suggest result is present or when a quick suggest + * result is dismissed. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + * @param {boolean} isPrivate + * Whether the engagement is in a private context. + * @param {UrlbarResult} result + * The quick suggest result that was present (and possibly picked) at the + * end of the engagement or that was dismissed. Null if no quick suggest + * result was present. + * @param {object} details + * The `details` object that was passed to `onEngagement()`. It must look + * like this: `{ selType, selIndex }` + */ + #recordEngagement(queryContext, isPrivate, result, details) { + let resultSelType = ""; + let resultClicked = false; + if (result && details.result == result) { + resultSelType = details.selType; + resultClicked = + details.element?.tagName != "menuitem" && + !details.element?.classList.contains("urlbarView-button") && + details.selType != "dismiss"; + } + + if (result) { + // Update impression stats. + lazy.QuickSuggest.impressionCaps.updateStats( + result.payload.isSponsored ? "sponsored" : "nonsponsored" + ); + + // Record engagement scalars, event, and pings. + this.#recordEngagementScalars({ result, resultSelType, resultClicked }); + this.#recordEngagementEvent({ result, resultSelType, resultClicked }); + if (!isPrivate) { + this.#recordEngagementPings({ result, resultSelType, resultClicked }); + } + } + + // Navigational suggestions telemetry requires special handling and does not + // depend on a result being visible. + if ( + lazy.UrlbarPrefs.get("recordNavigationalSuggestionTelemetry") && + queryContext.heuristicResult + ) { + this.#recordNavSuggestionTelemetry({ + queryContext, + result, + resultSelType, + resultClicked, + details, + }); + } + } + + /** + * Helper for engagement telemetry that records engagement scalars. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementScalars({ result, resultSelType, resultClicked }) { + // Navigational suggestion scalars are handled separately. + if (result.payload.telemetryType == "top_picks") { + return; + } + + // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the + // 0-based `result.rowIndex`. + let telemetryResultIndex = result.rowIndex + 1; + + let scalars = []; + switch (result.payload.telemetryType) { + case "adm_nonsponsored": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED); + break; + } + } + if (result.isBestMatch) { + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NONSPONSORED_BEST_MATCH); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_NONSPONSORED_BEST_MATCH); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH); + break; + } + } + } + break; + case "adm_sponsored": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED); + break; + } + } + if (result.isBestMatch) { + scalars.push(TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_SPONSORED_BEST_MATCH); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_SPONSORED_BEST_MATCH); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH); + break; + } + } + } + break; + case "wikipedia": + scalars.push(TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA); + } else { + switch (resultSelType) { + case "help": + scalars.push(TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA); + break; + case "dismiss": + scalars.push(TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA); + break; + } + } + break; + } + + for (let scalar of scalars) { + Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1); + } + } + + /** + * Helper for engagement telemetry that records the legacy engagement event. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementEvent({ result, resultSelType, resultClicked }) { + let eventType; + if (resultClicked) { + eventType = "click"; + } else if (!resultSelType) { + eventType = "impression_only"; + } else { + switch (resultSelType) { + case "dismiss": + eventType = "block"; + break; + case "help": + eventType = "help"; + break; + default: + eventType = "other"; + break; + } + } + + let suggestion_type; + switch (result.payload.telemetryType) { + case "adm_nonsponsored": + suggestion_type = "nonsponsored"; + break; + case "adm_sponsored": + suggestion_type = "sponsored"; + break; + case "top_picks": + suggestion_type = "navigational"; + break; + case "wikipedia": + suggestion_type = "dynamic-wikipedia"; + break; + default: + suggestion_type = result.payload.telemetryType; + break; + } + + Services.telemetry.recordEvent( + lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + "engagement", + eventType, + "", + { + suggestion_type, + match_type: result.isBestMatch ? "best-match" : "firefox-suggest", + // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based + position: String(result.rowIndex + 1), + source: result.payload.source, + } + ); + } + + /** + * Helper for engagement telemetry that records custom contextual services + * pings. + * + * @param {object} options + * Options object + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement. Must not be null. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + */ + #recordEngagementPings({ result, resultSelType, resultClicked }) { + if ( + result.payload.telemetryType != "adm_sponsored" && + result.payload.telemetryType != "adm_nonsponsored" + ) { + return; + } + + let payload = { + match_type: result.isBestMatch ? "best-match" : "firefox-suggest", + // Always use lowercase to make the reporting consistent + advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(), + block_id: result.payload.sponsoredBlockId, + improve_suggest_experience_checked: lazy.UrlbarPrefs.get( + "quicksuggest.dataCollection.enabled" + ), + // Quick suggest telemetry indexes are 1-based but `rowIndex` is 0-based + position: result.rowIndex + 1, + request_id: result.payload.requestId, + source: result.payload.source, + }; + + // impression + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + is_clicked: resultClicked, + reporting_url: result.payload.sponsoredImpressionUrl, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION + ); + + // click + if (resultClicked) { + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + reporting_url: result.payload.sponsoredClickUrl, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION + ); + } + + // dismiss + if (resultSelType == "dismiss") { + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + ...payload, + iab_category: result.payload.sponsoredIabCategory, + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK + ); + } + } + + /** + * Helper for engagement telemetry that records telemetry specific to + * navigational suggestions. + * + * @param {object} options + * Options object + * @param {UrlbarQueryContext} options.queryContext + * The query context. + * @param {UrlbarResult} options.result + * The quick suggest result related to the engagement, or null if no result + * was present. + * @param {string} options.resultSelType + * If an element in the result's row was clicked, this should be its + * `selType`. Otherwise it should be an empty string. + * @param {boolean} options.resultClicked + * True if the main part of the result's row was clicked; false if a button + * like help or dismiss was clicked or if no part of the row was clicked. + * @param {object} options.details + * The `details` object that was passed to `onEngagement()`. It must look + * like this: `{ selType, selIndex }` + */ + #recordNavSuggestionTelemetry({ + queryContext, + result, + resultSelType, + resultClicked, + details, + }) { + let scalars = []; + let heuristicClicked = + details.selIndex == 0 && queryContext.heuristicResult; + + if (result?.payload.telemetryType == "top_picks") { + // nav suggestion shown + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN); + if (resultClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV); + } else if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC); + } + } else if ( + this.#resultFromLastQuery?.payload.telemetryType == "top_picks" && + this.#resultFromLastQuery?.payload.dupedHeuristic + ) { + // nav suggestion duped heuristic + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED); + if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED); + } + } else { + // nav suggestion not matched or otherwise not shown + scalars.push(TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED); + if (heuristicClicked) { + scalars.push(TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED); + } + } + + let heuristicType = UrlbarUtils.searchEngagementTelemetryType( + queryContext.heuristicResult + ); + for (let scalar of scalars) { + Services.telemetry.keyedScalarAdd(scalar, heuristicType, 1); + } + } + + /** + * Cancels the current query. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + */ + cancelQuery(queryContext) { + // Cancel the Merino timeout timer so it doesn't fire and record a timeout. + // If it's already canceled or has fired, this is a no-op. + this.#merino?.cancelTimeoutTimer(); + + // Don't abort the Merino fetch if one is ongoing. By design we allow + // fetches to finish so we can record their latency. + } + + /** + * Fetches Merino suggestions. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + * @param {string} searchString + * The search string. + * @returns {Array} + * The Merino suggestions or null if there's an error or unexpected + * response. + */ + async _fetchMerinoSuggestions(queryContext, searchString) { + if (!this.#merino) { + this.#merino = new lazy.MerinoClient(this.name); + } + + let providers; + if ( + !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") && + !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") && + !lazy.UrlbarPrefs.get("merinoProviders") + ) { + // Data collection is enabled but suggestions are not. Use an empty list + // of providers to tell Merino not to fetch any suggestions. + providers = []; + } + + let suggestions = await this.#merino.fetch({ + providers, + query: searchString, + }); + + return suggestions; + } + + /** + * Returns whether a given suggestion can be added for a query, assuming the + * provider itself should be active. + * + * @param {object} suggestion + * The suggestion to check. + * @returns {boolean} + * Whether the suggestion can be added. + */ + async _canAddSuggestion(suggestion) { + this.logger.info("Checking if suggestion can be added"); + this.logger.debug(JSON.stringify({ suggestion })); + + // Return false if suggestions are disabled. + if ( + (suggestion.is_sponsored && + !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) || + (!suggestion.is_sponsored && + !lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored")) + ) { + this.logger.info("Suggestions disabled, not adding suggestion"); + return false; + } + + // Return false if an impression cap has been hit. + if ( + (suggestion.is_sponsored && + lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) || + (!suggestion.is_sponsored && + lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled")) + ) { + let type = suggestion.is_sponsored ? "sponsored" : "nonsponsored"; + let hitStats = lazy.QuickSuggest.impressionCaps.getHitStats(type); + if (hitStats) { + this.logger.info("Impression cap(s) hit, not adding suggestion"); + this.logger.debug(JSON.stringify({ type, hitStats })); + return false; + } + } + + // Return false if the suggestion is blocked. + if (await lazy.QuickSuggest.blockedSuggestions.has(suggestion.url)) { + this.logger.info("Suggestion blocked, not adding suggestion"); + return false; + } + + this.logger.info("Suggestion can be added"); + return true; + } + + get _test_merino() { + return this.#merino; + } + + // The result we added during the most recent query. + #resultFromLastQuery = null; + + // The Merino client. + #merino = null; +} + +export var UrlbarProviderQuickSuggest = new ProviderQuickSuggest(); diff --git a/browser/components/urlbar/UrlbarProviderRemoteTabs.sys.mjs b/browser/components/urlbar/UrlbarProviderRemoteTabs.sys.mjs new file mode 100644 index 0000000000..c4fc71bff3 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderRemoteTabs.sys.mjs @@ -0,0 +1,256 @@ +/* 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/. */ + +/** + * This module exports a provider that offers remote tabs. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +let _cache = null; + +// By default, we add remote tabs that have been used more recently than this +// time ago. Any remaining remote tabs are added in queue if no other results +// are found. +const RECENT_REMOTE_TAB_THRESHOLD_MS = 72 * 60 * 60 * 1000; // 72 hours. + +XPCOMUtils.defineLazyGetter(lazy, "weaveXPCService", function () { + try { + return Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + } catch (ex) { + // The app didn't build Sync. + } + return null; +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "showRemoteIconsPref", + "services.sync.syncedTabs.showRemoteIcons", + true +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "showRemoteTabsPref", + "services.sync.syncedTabs.showRemoteTabs", + true +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "syncUsernamePref", + "services.sync.username" +); + +// from MDN... +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Class used to create the provider. + */ +class ProviderRemoteTabs extends UrlbarProvider { + constructor() { + super(); + Services.obs.addObserver(this.observe, "weave:engine:sync:finish"); + Services.obs.addObserver(this.observe, "weave:service:start-over"); + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * + * @returns {string} + */ + get name() { + return "RemoteTabs"; + } + + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.NETWORK; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + lazy.syncUsernamePref && + lazy.showRemoteTabsPref && + lazy.UrlbarPrefs.get("suggest.remotetab") && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.TABS) && + lazy.weaveXPCService && + lazy.weaveXPCService.ready && + lazy.weaveXPCService.enabled + ); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let searchString = queryContext.tokens.map(t => t.value).join(" "); + + let re = new RegExp(escapeRegExp(searchString), "i"); + let tabsData = await this.ensureCache(); + if (instance != this.queryInstance) { + return; + } + + let resultsAdded = 0; + let staleTabs = []; + for (let { tab, client } of tabsData) { + if ( + !searchString || + searchString == lazy.UrlbarTokenizer.RESTRICT.OPENPAGE || + re.test(tab.url) || + (tab.title && re.test(tab.title)) + ) { + if (lazy.showRemoteIconsPref) { + if (!tab.icon) { + // It's rare that Sync supplies the icon for the page. If it does, it is a + // string URL. + tab.icon = UrlbarUtils.getIconForUrl(tab.url); + } else { + tab.icon = lazy.PlacesUtils.favicons.getFaviconLinkForIcon( + Services.io.newURI(tab.icon) + ).spec; + } + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + UrlbarUtils.RESULT_SOURCE.TABS, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [tab.url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [tab.title, UrlbarUtils.HIGHLIGHT.TYPED], + device: client.name, + icon: lazy.showRemoteIconsPref ? tab.icon : "", + lastUsed: (tab.lastUsed || 0) * 1000, + }) + ); + + // We want to return the most relevant remote tabs and thus the most + // recent ones. While SyncedTabs.jsm returns tabs that are sorted by + // most recent client, then most recent tab, we can do better. For + // example, the most recent client might have one recent tab and then + // many very stale tabs. Those very stale tabs will push out more recent + // tabs from staler clients. This provider first returns tabs from the + // last 72 hours, sorted by client recency. Then, it adds remaining + // tabs. We are not concerned about filling the remote tabs group with + // stale tabs, because the muxer ensures remote tabs flex with other + // results. It will only show the stale tabs if it has nothing else + // to show. + if ( + tab.lastUsed <= + (Date.now() - RECENT_REMOTE_TAB_THRESHOLD_MS) / 1000 + ) { + staleTabs.push(result); + } else { + addCallback(this, result); + resultsAdded++; + } + } + + if (resultsAdded == queryContext.maxResults) { + break; + } + } + + while (staleTabs.length && resultsAdded < queryContext.maxResults) { + addCallback(this, staleTabs.shift()); + resultsAdded++; + } + } + + /** + * Build the in-memory structure we use. + */ + async buildItems() { + // This is sorted by most recent client, most recent tab. + let tabsData = []; + // If Sync isn't initialized (either due to lag at startup or due to no user + // being signed in), don't reach in to Weave.Service as that may initialize + // Sync unnecessarily - we'll get an observer notification later when it + // becomes ready and has synced a list of tabs. + if (lazy.weaveXPCService.ready) { + let clients = await lazy.SyncedTabs.getTabClients(); + lazy.SyncedTabs.sortTabClientsByLastUsed(clients); + for (let client of clients) { + for (let tab of client.tabs) { + tabsData.push({ tab, client }); + } + } + } + return tabsData; + } + + /** + * Ensure the cache is good. + */ + async ensureCache() { + if (!_cache) { + _cache = await this.buildItems(); + } + return _cache; + } + + observe(subject, topic, data) { + switch (topic) { + case "weave:engine:sync:finish": + if (data == "tabs") { + // The tabs engine just finished syncing, so may have a different list + // of tabs then we previously cached. + _cache = null; + } + break; + + case "weave:service:start-over": + // Sync is being reset due to the user disconnecting - we must invalidate + // the cache so we don't supply tabs from a different user. + _cache = null; + break; + + default: + break; + } + } +} + +export var UrlbarProviderRemoteTabs = new ProviderRemoteTabs(); diff --git a/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs new file mode 100644 index 0000000000..f0f4b50af9 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs @@ -0,0 +1,579 @@ +/* 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/. */ + +/** + * This module exports a provider that offers search engine suggestions. + */ + +import { + SkippableTimer, + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Returns whether the passed in string looks like a url. + * + * @param {string} str + * The string to check. + * @param {boolean} [ignoreAlphanumericHosts] + * If true, don't consider a string with an alphanumeric host to be a URL. + * @returns {boolean} + * True if the query looks like a URL. + */ +function looksLikeUrl(str, ignoreAlphanumericHosts = false) { + // Single word including special chars. + return ( + !lazy.UrlbarTokenizer.REGEXP_SPACES.test(str) && + (["/", "@", ":", "["].some(c => str.includes(c)) || + (ignoreAlphanumericHosts + ? /^([\[\]A-Z0-9-]+\.){3,}[^.]+$/i.test(str) + : str.includes("."))) + ); +} + +/** + * Class used to create the provider. + */ +class ProviderSearchSuggestions extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "SearchSuggestions"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.NETWORK; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + // If the sources don't include search or the user used a restriction + // character other than search, don't allow any suggestions. + if ( + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || + (queryContext.restrictSource && + queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) + ) { + return false; + } + + // No suggestions for empty search strings, unless we are restricting to + // search or showing trending suggestions. + if ( + !queryContext.trimmedSearchString && + !this._isTokenOrRestrictionPresent(queryContext) && + !this.#shouldFetchTrending(queryContext) + ) { + return false; + } + + if (!this._allowSuggestions(queryContext)) { + return false; + } + + let wantsLocalSuggestions = + lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") && + (queryContext.trimmedSearchString || + lazy.UrlbarPrefs.get("update2.emptySearchBehavior") != 0); + + return wantsLocalSuggestions || this._allowRemoteSuggestions(queryContext); + } + + /** + * Returns whether the user typed a token alias or restriction token, or is in + * search mode. We use this value to override the pref to disable search + * suggestions in the Urlbar. + * + * @param {UrlbarQueryContext} queryContext The query context object. + * @returns {boolean} True if the user typed a token alias or search + * restriction token. + */ + _isTokenOrRestrictionPresent(queryContext) { + return ( + queryContext.searchString.startsWith("@") || + (queryContext.restrictSource && + queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) || + queryContext.tokens.some( + t => t.type == lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH + ) || + (queryContext.searchMode && + queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH)) + ); + } + + /** + * Returns whether suggestions in general are allowed for a given query + * context. If this returns false, then we shouldn't fetch either form + * history or remote suggestions. + * + * @param {object} queryContext The query context object + * @returns {boolean} True if suggestions in general are allowed and false if + * not. + */ + _allowSuggestions(queryContext) { + if ( + // If the user typed a restriction token or token alias, we ignore the + // pref to disable suggestions in the Urlbar. + (!lazy.UrlbarPrefs.get("suggest.searches") && + !this._isTokenOrRestrictionPresent(queryContext)) || + !lazy.UrlbarPrefs.get("browser.search.suggest.enabled") || + (queryContext.isPrivate && + !lazy.UrlbarPrefs.get("browser.search.suggest.enabled.private")) + ) { + return false; + } + return true; + } + + /** + * Returns whether remote suggestions are allowed for a given query context. + * + * @param {object} queryContext The query context object + * @param {string} [searchString] The effective search string without + * restriction tokens or aliases. Defaults to the context searchString. + * @returns {boolean} True if remote suggestions are allowed and false if not. + */ + _allowRemoteSuggestions( + queryContext, + searchString = queryContext.searchString + ) { + // This is checked by `queryContext.allowRemoteResults` below, but we can + // short-circuit that call with the `_isTokenOrRestrictionPresent` block + // before that. Make sure we don't allow remote suggestions if this is set. + if (queryContext.prohibitRemoteResults) { + return false; + } + + // Allow remote suggestions if trending suggestions are enabled. + if (this.#shouldFetchTrending(queryContext)) { + return true; + } + + if (!searchString.trim()) { + return false; + } + + // Skip all remaining checks and allow remote suggestions at this point if + // the user used a token alias or restriction token. We want "@engine query" + // to return suggestions from the engine. We'll return early from startQuery + // if the query doesn't match an alias. + if (this._isTokenOrRestrictionPresent(queryContext)) { + return true; + } + + // If the user is just adding on to a query that previously didn't return + // many remote suggestions, we are unlikely to get any more results. + if ( + !!this._lastLowResultsSearchSuggestion && + searchString.length > this._lastLowResultsSearchSuggestion.length && + searchString.startsWith(this._lastLowResultsSearchSuggestion) + ) { + return false; + } + + return queryContext.allowRemoteResults( + searchString, + lazy.UrlbarPrefs.get("trending.featureGate") + ); + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let aliasEngine = await this._maybeGetAlias(queryContext); + if (!aliasEngine) { + // Autofill matches queries starting with "@" to token alias engines. + // If the string starts with "@", but an alias engine is not yet + // matched, then autofill might still be filtering token alias + // engine results. We don't want to mix search suggestions with those + // engine results, so we return early. See bug 1551049 comment 1 for + // discussion on how to improve this behavior. + if (queryContext.searchString.startsWith("@")) { + return; + } + } + + let query = aliasEngine + ? aliasEngine.query + : UrlbarUtils.substringAt( + queryContext.searchString, + queryContext.tokens[0]?.value || "" + ).trim(); + + let leadingRestrictionToken = null; + if ( + lazy.UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && + (queryContext.tokens.length > 1 || + queryContext.tokens[0].type == + lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH) + ) { + leadingRestrictionToken = queryContext.tokens[0].value; + } + + // Strip a leading search restriction char, because we prepend it to text + // when the search shortcut is used and it's not user typed. Don't strip + // other restriction chars, so that it's possible to search for things + // including one of those (e.g. "c#"). + if (leadingRestrictionToken === lazy.UrlbarTokenizer.RESTRICT.SEARCH) { + query = UrlbarUtils.substringAfter(query, leadingRestrictionToken).trim(); + } + + // Find our search engine. It may have already been set with an alias. + let engine; + if (aliasEngine) { + engine = aliasEngine.engine; + } else if (queryContext.searchMode?.engineName) { + engine = lazy.UrlbarSearchUtils.getEngineByName( + queryContext.searchMode.engineName + ); + } else { + engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); + } + + if (!engine) { + return; + } + + let alias = (aliasEngine && aliasEngine.alias) || ""; + let results = await this._fetchSearchSuggestions( + queryContext, + engine, + query, + alias + ); + + if (!results || instance != this.queryInstance) { + return; + } + + for (let result of results) { + addCallback(this, result); + } + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + if (this.#shouldFetchTrending(queryContext)) { + return lazy.UrlbarProviderTopSites.PRIORITY; + } + return 0; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._suggestionsController) { + this._suggestionsController.stop(); + this._suggestionsController = null; + } + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result } = details; + if (result?.providerName != this.name) { + return; + } + + if (details.selType == "dismiss" && queryContext.formHistoryName) { + lazy.FormHistory.update({ + op: "remove", + fieldname: queryContext.formHistoryName, + value: result.payload.suggestion, + }).catch(error => + console.error(`Removing form history failed: ${error}`) + ); + queryContext.view.controller.removeResult(result); + } + } + + async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { + if (!engine) { + return null; + } + + this._suggestionsController = new lazy.SearchSuggestionController( + queryContext.formHistoryName + ); + + // If there's a form history entry that equals the search string, the search + // suggestions controller will include it, and we'll make a result for it. + // If the heuristic result ends up being a search result, the muxer will + // discard the form history result since it dupes the heuristic, and the + // final list of results would be left with `count` - 1 form history results + // instead of `count`. Therefore we request `count` + 1 entries. The muxer + // will dedupe and limit the final form history count as appropriate. + this._suggestionsController.maxLocalResults = queryContext.maxResults + 1; + + // Request maxResults + 1 remote suggestions for the same reason we request + // maxResults + 1 form history entries. + let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); + this._suggestionsController.maxRemoteResults = allowRemote + ? queryContext.maxResults + 1 + : 0; + + if (allowRemote && this.#shouldFetchTrending(queryContext)) { + if ( + queryContext.searchMode && + lazy.UrlbarPrefs.get("trending.maxResultsSearchMode") != -1 + ) { + this._suggestionsController.maxRemoteResults = lazy.UrlbarPrefs.get( + "trending.maxResultsSearchMode" + ); + } else if ( + !queryContext.searchMode && + lazy.UrlbarPrefs.get("trending.maxResultsNoSearchMode") != -1 + ) { + this._suggestionsController.maxRemoteResults = lazy.UrlbarPrefs.get( + "trending.maxResultsNoSearchMode" + ); + } + } + + this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( + searchString, + queryContext.isPrivate, + engine, + queryContext.userContextId, + this._isTokenOrRestrictionPresent(queryContext), + false, + this.#shouldFetchTrending(queryContext) + ); + + // See `SearchSuggestionsController.fetch` documentation for a description + // of `fetchData`. + let fetchData = await this._suggestionsFetchCompletePromise; + // The fetch was canceled. + if (!fetchData) { + return null; + } + + let results = []; + + // maxHistoricalSearchSuggestions used to determine the initial number of + // form history results, with the special case where zero means to never + // show form history at all. With the introduction of flexed result + // groups, we now use it only as a boolean: Zero means don't show form + // history at all (as before), non-zero means show it. + if (lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions")) { + for (let entry of fetchData.local) { + results.push(makeFormHistoryResult(queryContext, engine, entry)); + } + } + + // If we don't return many results, then keep track of the query. If the + // user just adds on to the query, we won't fetch more suggestions if the + // query is very long since we are unlikely to get any. + if ( + allowRemote && + !fetchData.remote.length && + searchString.length > lazy.UrlbarPrefs.get("maxCharsForSearchSuggestions") + ) { + this._lastLowResultsSearchSuggestion = searchString; + } + + // If we have only tail suggestions, we only show them if we have no other + // results. We need to wait for other results to arrive to avoid flickering. + // We will wait for this timer unless we have suggestions that don't have a + // tail. + let tailTimer = new SkippableTimer({ + name: "ProviderSearchSuggestions", + time: 100, + logger: this.logger, + }); + + for (let entry of fetchData.remote) { + if (looksLikeUrl(entry.value)) { + continue; + } + + let tail = entry.tail; + let tailPrefix = entry.matchPrefix; + + // Skip tail suggestions if the pref is disabled. + if (tail && !lazy.UrlbarPrefs.get("richSuggestions.tail")) { + continue; + } + + if (!tail) { + await tailTimer.fire().catch(ex => this.logger.error(ex)); + } + + try { + results.push( + new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + tailPrefix, + tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailOffsetIndex: tail ? entry.tailOffsetIndex : undefined, + keyword: [ + alias ? alias : undefined, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + trending: entry.trending, + isRichSuggestion: !!entry.icon, + description: entry.description || undefined, + query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], + icon: !entry.value ? engine.iconURI?.spec : entry.icon, + } + ) + ) + ); + } catch (err) { + this.logger.error(err); + continue; + } + } + + await tailTimer.promise; + return results; + } + + /** + * @typedef {object} EngineAlias + * + * @property {nsISearchEngine} engine + * The search engine + * @property {string} alias + * The search engine's alias + * @property {string} query + * The remainder of the search engine string after the alias + */ + + /** + * Searches for an engine alias given the queryContext. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {EngineAlias?} aliasEngine + * A representation of the aliased engine. Null if there's no match. + */ + async _maybeGetAlias(queryContext) { + if (queryContext.searchMode) { + // If we're in search mode, don't try to parse an alias at all. + return null; + } + + let possibleAlias = queryContext.tokens[0]?.value; + // "@" on its own is handled by UrlbarProviderTokenAliasEngines and returns + // a list of every available token alias. + if (!possibleAlias || possibleAlias == "@") { + return null; + } + + let query = UrlbarUtils.substringAfter( + queryContext.searchString, + possibleAlias + ); + + // Match an alias only when it has a space after it. If there's no trailing + // space, then continue to treat it as part of the search string. + if (!lazy.UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return null; + } + + // Check if the user entered an engine alias directly. + let engineMatch = await lazy.UrlbarSearchUtils.engineForAlias( + possibleAlias + ); + if (engineMatch) { + return { + engine: engineMatch, + alias: possibleAlias, + query: query.trim(), + }; + } + + return null; + } + + /** + * Whether we should show trending suggestions. These are shown when the + * user enters a specific engines searchMode when enabled, the + * seperate `requireSearchMode` pref controls whether they are visible + * when the urlbar is first opened without any search mode. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {boolean} + * Whether we should fetch trending results. + */ + #shouldFetchTrending(queryContext) { + return !!( + queryContext.searchString == "" && + lazy.UrlbarPrefs.get("trending.featureGate") && + (queryContext.searchMode || + !lazy.UrlbarPrefs.get("trending.requireSearchMode")) + ); + } +} + +function makeFormHistoryResult(queryContext, engine, entry) { + return new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engine.name, + suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + }) + ); +} + +export var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); diff --git a/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs new file mode 100644 index 0000000000..309795004a --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchTips.sys.mjs @@ -0,0 +1,629 @@ +/* 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/. */ + +/** + * This module exports a provider that might show a tip when the user opens + * the newtab or starts an organic search with their default search engine. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + DefaultBrowserCheck: "resource:///modules/BrowserGlue.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "updateManager", () => { + return ( + Cc["@mozilla.org/updates/update-manager;1"] && + Cc["@mozilla.org/updates/update-manager;1"].getService(Ci.nsIUpdateManager) + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "cfrFeaturesUserPref", + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + true +); + +// The possible tips to show. These names (except NONE) are used in the names +// of keys in the `urlbar.tips` keyed scalar telemetry (see telemetry.rst). +// Don't modify them unless you've considered that. If you do modify them or +// add new tips, then you are also adding new `urlbar.tips` keys and therefore +// need an expanded data collection review. +const TIPS = { + NONE: "", + ONBOARD: "searchTip_onboard", + PERSIST: "searchTip_persist", + REDIRECT: "searchTip_redirect", +}; + +// This maps engine names to regexes matching their homepages. We show the +// redirect tip on these pages. The Google domains are taken from +// https://ipfs.io/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki/List_of_Google_domains.html. +const SUPPORTED_ENGINES = new Map([ + ["Bing", { domainPath: /^www\.bing\.com\/$/ }], + [ + "DuckDuckGo", + { + domainPath: /^(start\.)?duckduckgo\.com\/$/, + prohibitedSearchParams: ["q"], + }, + ], + [ + "Google", + { + domainPath: + /^www\.google\.(com|ac|ad|ae|com\.af|com\.ag|com\.ai|al|am|co\.ao|com\.ar|as|at|com\.au|az|ba|com\.bd|be|bf|bg|com\.bh|bi|bj|com\.bn|com\.bo|com\.br|bs|bt|co\.bw|by|com\.bz|ca|com\.kh|cc|cd|cf|cat|cg|ch|ci|co\.ck|cl|cm|cn|com\.co|co\.cr|com\.cu|cv|com\.cy|cz|de|dj|dk|dm|com\.do|dz|com\.ec|ee|com\.eg|es|com\.et|fi|com\.fj|fm|fr|ga|ge|gf|gg|com\.gh|com\.gi|gl|gm|gp|gr|com\.gt|gy|com\.hk|hn|hr|ht|hu|co\.id|iq|ie|co\.il|im|co\.in|io|is|it|je|com\.jm|jo|co\.jp|co\.ke|ki|kg|co\.kr|com\.kw|kz|la|com\.lb|com\.lc|li|lk|co\.ls|lt|lu|lv|com\.ly|co\.ma|md|me|mg|mk|ml|com\.mm|mn|ms|com\.mt|mu|mv|mw|com\.mx|com\.my|co\.mz|com\.na|ne|com\.nf|com\.ng|com\.ni|nl|no|com\.np|nr|nu|co\.nz|com\.om|com\.pk|com\.pa|com\.pe|com\.ph|pl|com\.pg|pn|com\.pr|ps|pt|com\.py|com\.qa|ro|rs|ru|rw|com\.sa|com\.sb|sc|se|com\.sg|sh|si|sk|com\.sl|sn|sm|so|st|sr|com\.sv|td|tg|co\.th|com\.tj|tk|tl|tm|to|tn|com\.tr|tt|com\.tw|co\.tz|com\.ua|co\.ug|co\.uk|com\.uy|co\.uz|com\.vc|co\.ve|vg|co\.vi|com\.vn|vu|ws|co\.za|co\.zm|co\.zw)\/(webhp)?$/, + }, + ], +]); + +// The maximum number of times we'll show a tip across all sessions. +const MAX_SHOWN_COUNT = 4; + +// Amount of time to wait before showing a tip after selecting a tab or +// navigating to a page where we should show a tip. +const SHOW_TIP_DELAY_MS = 200; + +// Amount of time to wait before showing the persist tip after the +// onLocationChange event during the process of loading +// a default search engine results page. +const SHOW_PERSIST_TIP_DELAY_MS = 1500; + +// We won't show a tip if the browser has been updated in the past +// LAST_UPDATE_THRESHOLD_MS. +const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +/** + * A provider that sometimes returns a tip result when the user visits the + * newtab page or their default search engine's homepage. + */ +class ProviderSearchTips extends UrlbarProvider { + constructor() { + super(); + + // Whether we should disable tips for the current browser session, for + // example because a tip was already shown. + this.disableTipsForCurrentSession = true; + for (let tip of Object.values(TIPS)) { + if ( + tip && + lazy.UrlbarPrefs.get(`tipShownCount.${tip}`) < MAX_SHOWN_COUNT + ) { + this.disableTipsForCurrentSession = false; + break; + } + } + + // Whether and what kind of tip we've shown in the current engagement. + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + + // Used to track browser windows we've seen. + this._seenWindows = new WeakSet(); + } + + /** + * Enum of the types of search tips. + * + * @returns {{ NONE: string; ONBOARD: string; PERSIST: string; REDIRECT: string; }} + */ + get TIP_TYPE() { + return TIPS; + } + + get PRIORITY() { + // Search tips are prioritized over the Places and top sites providers. + return lazy.UrlbarProviderTopSites.PRIORITY + 1; + } + + get SHOW_PERSIST_TIP_DELAY_MS() { + return SHOW_PERSIST_TIP_DELAY_MS; + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + * + * @returns {string} + */ + get name() { + return "UrlbarProviderSearchTips"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return this.currentTip && lazy.cfrFeaturesUserPref; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return this.PRIORITY; + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + + let tip = this.currentTip; + this.showedTipTypeInCurrentEngagement = this.currentTip; + this.currentTip = TIPS.NONE; + + let defaultEngine = await Services.search.getDefault(); + if (instance != this.queryInstance) { + return; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: tip, + buttons: [{ l10n: { id: "urlbar-search-tips-confirm" } }], + icon: defaultEngine.iconURI?.spec, + } + ); + + switch (tip) { + case TIPS.ONBOARD: + result.heuristic = true; + result.payload.titleL10n = { + id: "urlbar-search-tips-onboard", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.REDIRECT: + result.heuristic = false; + result.payload.titleL10n = { + id: "urlbar-search-tips-redirect-2", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.PERSIST: + result.heuristic = false; + result.payload.titleL10n = { + id: "urlbar-search-tips-persist", + }; + result.payload.icon = UrlbarUtils.ICON.TIP; + result.payload.buttons = [ + { l10n: { id: "urlbar-search-tips-confirm-short" } }, + ]; + break; + } + + Services.telemetry.keyedScalarAdd("urlbar.tips", `${tip}-shown`, 1); + + addCallback(this, result); + } + + /** + * Called when the tip is selected. + * + * @param {UrlbarResult} result + * The result that was picked. + * @param {window} window + * The browser window in which the tip is being displayed. + */ + #pickResult(result, window) { + let tip = result.payload.type; + switch (tip) { + case TIPS.PERSIST: + window.gURLBar.removeAttribute("suppress-focus-border"); + window.gURLBar.select(); + break; + default: + window.gURLBar.value = ""; + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.removeAttribute("suppress-focus-border"); + window.gURLBar.focus(); + break; + } + + // The user either clicked the tip's "Okay, Got It" button, or they clicked + // in the urlbar while the tip was showing. We treat both as the user's + // acknowledgment of the tip, and we don't show tips again in any session. + // Set the shown count to the max. + lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, MAX_SHOWN_COUNT); + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + * @param {window} window + * The browser window where the engagement event took place. + */ + onEngagement(isPrivate, state, queryContext, details, window) { + // Ignore engagements on other results that didn't end the session. + let { result } = details; + if (result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + if (result?.providerName == this.name) { + this.#pickResult(result, window); + } + + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + } + + /** + * Called from `onLocationChange` in browser.js. + * + * @param {window} window + * The browser window where the location change happened. + * @param {nsIURI} uri + * The URI being navigated to. + * @param {nsIURI | null} originalUri + * The original URI being navigated to. + * @param {nsIWebProgress} webProgress + * The progress object, which can have event listeners added to it. + * @param {number} flags + * Load flags. See nsIWebProgressListener.idl for possible values. + */ + async onLocationChange(window, uri, originalUri, webProgress, flags) { + let instance = (this._onLocationChangeInstance = {}); + + // If this is the first time we've seen this browser window, we take some + // precautions to avoid impacting ts_paint. + if (!this._seenWindows.has(window)) { + this._seenWindows.add(window); + + // First, wait until MozAfterPaint is fired in the current content window. + await window.gBrowserInit.firstContentWindowPaintPromise; + if (instance != this._onLocationChangeInstance) { + return; + } + + // Second, wait 500ms. ts_paint waits at most 500ms after MozAfterPaint + // before ending. We use XPCOM directly instead of Timer.sys.mjs to avoid the + // perf impact of loading Timer.sys.mjs, in case it's not already loaded. + await new Promise(resolve => { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(resolve, 500, Ci.nsITimer.TYPE_ONE_SHOT); + }); + if (instance != this._onLocationChangeInstance) { + return; + } + } + + // Ignore events that don't change the document. Google is known to do this. + // Also ignore changes in sub-frames. See bug 1623978. + if ( + flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT || + !webProgress.isTopLevel + ) { + return; + } + + // The UrlbarView is usually closed on location change when the input is + // blurred. Since we open the view to show the redirect tip without focusing + // the input, the view won't close in that case. We need to close it + // manually. + if (this.showedTipTypeInCurrentEngagement != TIPS.NONE) { + window.gURLBar.view.close(); + } + + // Check if we are supposed to show a tip for the current session. + if ( + !lazy.cfrFeaturesUserPref || + (this.disableTipsForCurrentSession && + !lazy.UrlbarPrefs.get("searchTips.test.ignoreShowLimits")) + ) { + return; + } + + this._maybeShowTipForUrl(uri.spec, originalUri, window).catch(ex => + this.logger.error(ex) + ); + } + + /** + * Determines whether we should show a tip for the current tab, sets + * this.currentTip, and starts a search on an empty string. + * + * @param {string} urlStr + * The URL of the page being loaded, in string form. + * @param {nsIURI | null} originalUri + * The original URI of the page being loaded. + * @param {window} window + * The browser window in which the tip is being displayed. + */ + async _maybeShowTipForUrl(urlStr, originalUri, window) { + let instance = {}; + this._maybeShowTipForUrlInstance = instance; + + let ignoreShowLimits = lazy.UrlbarPrefs.get( + "searchTips.test.ignoreShowLimits" + ); + + // Determine which tip we should show for the tab. Do this check first + // before the others below. It has less of a performance impact than the + // others, so in the common case where the URL is not one we're interested + // in, we can return immediately. + let tip; + let isNewtab = ["about:newtab", "about:home"].includes(urlStr); + let isSearchHomepage = !isNewtab && (await isDefaultEngineHomepage(urlStr)); + + // Only show the persist tip if: the feature is enabled, + // it's been shown fewer than the maximum number of times + // a specific tip can be shown to the user, and the + // the url is a default SERP. + let shouldShowPersistTip = + lazy.UrlbarPrefs.isPersistedSearchTermsEnabled() && + (lazy.UrlbarPrefs.get(`tipShownCount.${TIPS.PERSIST}`) < + MAX_SHOWN_COUNT || + ignoreShowLimits) && + !!lazy.UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + originalUri ?? urlStr + ); + + if (isNewtab) { + tip = TIPS.ONBOARD; + } else if (isSearchHomepage) { + tip = TIPS.REDIRECT; + } else if (shouldShowPersistTip) { + tip = TIPS.PERSIST; + } else { + // No tip. + return; + } + + // If we've shown this type of tip the maximum number of times over all + // sessions, don't show it again. + let shownCount = lazy.UrlbarPrefs.get(`tipShownCount.${tip}`); + if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser has been updated recently. + // Exception: TIPS.PERSIST should show immediately + // after the feature is enabled for users. + let date = await lastBrowserUpdateDate(); + if ( + tip != TIPS.PERSIST && + Date.now() - date <= LAST_UPDATE_THRESHOLD_MS && + !ignoreShowLimits + ) { + return; + } + + let tipDelay = + tip == TIPS.PERSIST ? SHOW_PERSIST_TIP_DELAY_MS : SHOW_TIP_DELAY_MS; + + // Start a search. + lazy.setTimeout(async () => { + if (this._maybeShowTipForUrlInstance != instance) { + return; + } + + // We don't want to interrupt a user's typed query with a Search Tip. + // See bugs 1613662 and 1619547. The persist search tip is an + // exception because the query is not erased. + if ( + window.gURLBar.getAttribute("pageproxystate") == "invalid" && + window.gURLBar.value != "" + ) { + return; + } + + // The tab that initiated the tip might not be in the same window + // as the one that is currently at the top. Only apply this search + // tip to a tab showing a search term. + if (tip == TIPS.PERSIST && !window.gBrowser.selectedBrowser.searchTerms) { + return; + } + + // Don't show a tip if the browser is already showing some other + // notification. + if ( + (!ignoreShowLimits && (await isBrowserShowingNotification(window))) || + this._maybeShowTipForUrlInstance != instance + ) { + return; + } + + // Don't show a tip if a request is in progress, and the URI associated + // with the request differs from the URI that triggered the search tip. + // One contraint with this approach is related to Bug 1797748: SERPs + // that use the History API to navigate between views will call + // onLocationChange without a request, and thus, no originalUri is + // available to check against, so the search tip and search terms may + // show on search pages outside of the default SERP. + let { documentRequest } = window.gBrowser.selectedBrowser.webProgress; + if ( + documentRequest instanceof Ci.nsIChannel && + documentRequest.originalURI?.spec != originalUri?.spec && + (!isNewtab || originalUri) + ) { + return; + } + + // At this point, we're showing a tip. + this.disableTipsForCurrentSession = true; + + // Store the new shown count. + lazy.UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1); + + this.currentTip = tip; + + let value = + tip == TIPS.PERSIST ? window.gBrowser.selectedBrowser.searchTerms : ""; + window.gURLBar.search(value, { focus: tip == TIPS.ONBOARD }); + }, tipDelay); + } +} + +async function isBrowserShowingNotification(window) { + // urlbar view and notification box (info bar) + if ( + window.gURLBar.view.isOpen || + window.gNotificationBox.currentNotification || + window.gBrowser.getNotificationBox().currentNotification + ) { + return true; + } + + // app menu notification doorhanger + if ( + lazy.AppMenuNotifications.activeNotification && + !lazy.AppMenuNotifications.activeNotification.dismissed && + !lazy.AppMenuNotifications.activeNotification.options.badgeOnly + ) { + return true; + } + + // PopupNotifications (e.g. Tracking Protection, Identity Box Doorhangers) + if (window.PopupNotifications.isPanelOpen) { + return true; + } + + // page action button panels + let pageActions = window.document.getElementById("page-action-buttons"); + if (pageActions) { + for (let child of pageActions.childNodes) { + if (child.getAttribute("open") == "true") { + return true; + } + } + } + + // toolbar button panels + let navbar = window.document.getElementById("nav-bar-customization-target"); + for (let node of navbar.querySelectorAll("toolbarbutton")) { + if (node.getAttribute("open") == "true") { + return true; + } + } + + // Other modals like spotlight messages or default browser prompt + // can be shown at startup + if (window.gDialogBox.isOpen) { + return true; + } + + // On startup, the default browser check normally opens after the Search Tip. + // As a result, we can't check for the prompt's presence, but we can check if + // it plans on opening. + const willPrompt = await lazy.DefaultBrowserCheck.willCheckDefaultBrowser( + /* isStartupCheck */ false + ); + if (willPrompt) { + return true; + } + + return false; +} + +/** + * Checks if the given URL is the homepage of the current default search engine. + * Returns false if the default engine is not listed in SUPPORTED_ENGINES. + * + * @param {string} urlStr + * The URL to check, in string form. + * + * @returns {boolean} + */ +async function isDefaultEngineHomepage(urlStr) { + let defaultEngine = await Services.search.getDefault(); + if (!defaultEngine) { + return false; + } + + let homepageMatches = SUPPORTED_ENGINES.get(defaultEngine.name); + if (!homepageMatches) { + return false; + } + + // The URL object throws if the string isn't a valid URL. + let url; + try { + url = new URL(urlStr); + } catch (e) { + return false; + } + + if (url.searchParams.has(homepageMatches.prohibitedSearchParams)) { + return false; + } + + // Strip protocol and query params. + urlStr = url.hostname.concat(url.pathname); + + return homepageMatches.domainPath.test(urlStr); +} + +async function lastBrowserUpdateDate() { + // Get the newest update in the update history. This isn't perfect + // because these dates are when updates are applied, not when the + // user restarts with the update. See bug 1595328. + if (lazy.updateManager && lazy.updateManager.getUpdateCount()) { + let update = lazy.updateManager.getUpdateAt(0); + return update.installDate; + } + // Fall back to the profile age. + let age = await lazy.ProfileAge(); + return (await age.firstUse) || age.created; +} + +export var UrlbarProviderSearchTips = new ProviderSearchTips(); diff --git a/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs new file mode 100644 index 0000000000..7a35694b8e --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs @@ -0,0 +1,492 @@ +/* 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/. */ + +/** + * This module exports a provider that offers a search engine when the user is + * typing a search engine domain. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +const DYNAMIC_RESULT_TYPE = "onboardTabToSearch"; +const VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "no-wrap", + tag: "span", + classList: ["urlbarView-no-wrap"], + children: [ + { + name: "icon", + tag: "img", + classList: ["urlbarView-favicon"], + }, + { + name: "text-container", + tag: "span", + children: [ + { + name: "first-row-container", + tag: "span", + children: [ + { + name: "title", + tag: "span", + classList: ["urlbarView-title"], + children: [ + { + name: "titleStrong", + tag: "strong", + }, + ], + }, + { + name: "title-separator", + tag: "span", + classList: ["urlbarView-title-separator"], + }, + { + name: "action", + tag: "span", + classList: ["urlbarView-action"], + attributes: { + "slide-in": true, + }, + }, + ], + }, + { + name: "description", + tag: "span", + }, + ], + }, + ], + }, + ], +}; + +/** + * Initializes this provider's dynamic result. To be called after the creation + * of the provider singleton. + */ +function initializeDynamicResult() { + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); +} + +/** + * Class used to create the provider. + */ +class ProviderTabToSearch extends UrlbarProvider { + constructor() { + super(); + this.enginesShown = { + onboarding: new Set(), + regular: new Set(), + }; + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "TabToSearch"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + async isActive(queryContext) { + return ( + queryContext.searchString && + queryContext.tokens.length == 1 && + !queryContext.searchMode && + lazy.UrlbarPrefs.get("suggest.engines") + ); + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return 0; + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result, idsByName) { + return { + icon: { + attributes: { + src: result.payload.icon, + }, + }, + titleStrong: { + l10n: { + id: "urlbar-result-action-search-w-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + action: { + l10n: { + id: result.payload.isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + description: { + l10n: { + id: "urlbar-tabtosearch-onboard", + }, + }, + }; + } + + /** + * Called when a result from the provider is selected. "Selected" refers to + * the user highlighing the result with the arrow keys/Tab, before it is + * picked. onSelection is also called when a user clicks a result. In the + * event of a click, onSelection is called just before onEngagement. + * + * @param {UrlbarResult} result + * The result that was selected. + * @param {Element} element + * The element in the result's view that was selected. + */ + onSelection(result, element) { + // We keep track of the number of times the user interacts with + // tab-to-search onboarding results so we stop showing them after + // `tabToSearch.onboard.interactionsLeft` interactions. + // Also do not increment the counter if the result was interacted with less + // than 5 minutes ago. This is a guard against the user running up the + // counter by interacting with the same result repeatedly. + if ( + result.payload.dynamicType && + (!this.onboardingInteractionAtTime || + this.onboardingInteractionAtTime < Date.now() - 1000 * 60 * 5) + ) { + let interactionsLeft = lazy.UrlbarPrefs.get( + "tabToSearch.onboard.interactionsLeft" + ); + + if (interactionsLeft > 0) { + lazy.UrlbarPrefs.set( + "tabToSearch.onboard.interactionsLeft", + --interactionsLeft + ); + } + + this.onboardingInteractionAtTime = Date.now(); + } + } + + /** + * Called when the user starts and ends an engagement with the urlbar. For + * details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This is defined only when `state` is "engagement" or "abandonment", and + * it describes the search string and picked result. + */ + onEngagement(isPrivate, state, queryContext, details) { + let { result, element } = details; + if ( + result?.providerName == this.name && + result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + // Confirm search mode, but only for the onboarding (dynamic) result. The + // input will handle confirming search mode for the non-onboarding + // `RESULT_TYPE.SEARCH` result since it sets `providesSearchMode`. + element.ownerGlobal.gURLBar.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + }); + } + + if (!this.enginesShown.regular.size && !this.enginesShown.onboarding.size) { + return; + } + + try { + // urlbar.tabtosearch.* is prerelease-only/opt-in for now. See bug 1686330. + for (let engine of this.enginesShown.regular) { + let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({ + engineName: engine, + }); + Services.telemetry.keyedScalarAdd( + "urlbar.tabtosearch.impressions", + scalarKey, + 1 + ); + } + for (let engine of this.enginesShown.onboarding) { + let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({ + engineName: engine, + }); + Services.telemetry.keyedScalarAdd( + "urlbar.tabtosearch.impressions_onboarding", + scalarKey, + 1 + ); + } + + // We also record in urlbar.tips because only it has been approved for use + // in release channels. + Services.telemetry.keyedScalarAdd( + "urlbar.tips", + "tabtosearch-shown", + this.enginesShown.regular.size + ); + Services.telemetry.keyedScalarAdd( + "urlbar.tips", + "tabtosearch_onboard-shown", + this.enginesShown.onboarding.size + ); + } catch (ex) { + // If your test throws this error or causes another test to throw it, it + // is likely because your test showed a tab-to-search result but did not + // start and end the engagement in which it was shown. Be sure to fire an + // input event to start an engagement and blur the Urlbar to end it. + this.logger.error( + `Exception while recording TabToSearch telemetry: ${ex})` + ); + } finally { + // Even if there's an exception, we want to clear these Sets. Otherwise, + // we might get into a state where we repeatedly run the same engines + // through the code above and never record telemetry, because there's an + // error every time. + this.enginesShown.regular.clear(); + this.enginesShown.onboarding.clear(); + } + } + + /** + * Defines whether the view should defer user selection events while waiting + * for the first result from this provider. + * + * @returns {boolean} Whether the provider wants to defer user selection + * events. + */ + get deferUserSelection() { + return true; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + * @returns {Promise} resolved when the query stops. + */ + async startQuery(queryContext, addCallback) { + // enginesForDomainPrefix only matches against engine domains. + // Remove trailing slashes and www. from the search string and check if the + // resulting string is worth matching. + let [searchStr] = UrlbarUtils.stripPrefixAndTrim( + queryContext.searchString, + { + stripWww: true, + trimSlash: true, + } + ); + // Skip any string that cannot be an origin. + if ( + !lazy.UrlbarTokenizer.looksLikeOrigin(searchStr, { + ignoreKnownDomains: true, + noIp: true, + }) + ) { + return; + } + + // Also remove the public suffix, if present, to allow for partial matches. + if (searchStr.includes(".")) { + searchStr = UrlbarUtils.stripPublicSuffixFromHost(searchStr); + } + + // Add all matching engines. + let engines = await lazy.UrlbarSearchUtils.enginesForDomainPrefix( + searchStr, + { + matchAllDomainLevels: true, + onlyEnabled: true, + } + ); + if (!engines.length) { + return; + } + + const onboardingInteractionsLeft = lazy.UrlbarPrefs.get( + "tabToSearch.onboard.interactionsLeft" + ); + + // If the engine host begins with the search string, autofill may happen + // for it, and the Muxer will retain the result only if there's a matching + // autofill heuristic result. + // Otherwise, we may have a partial match, where the search string is at + // the boundary of a host part, for example "wiki" in "en.wikipedia.org". + // We put those engines apart, and later we check if their host satisfies + // the autofill threshold. If they do, we mark them with the + // "satisfiesAutofillThreshold" payload property, so the muxer can avoid + // filtering them out. + let partialMatchEnginesByHost = new Map(); + + for (let engine of engines) { + // Trim the engine host. This will also be set as the result url, so the + // Muxer can use it to filter. + let [host] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + // Check if the host may be autofilled. + if (host.startsWith(searchStr.toLocaleLowerCase())) { + if (onboardingInteractionsLeft > 0) { + addCallback(this, makeOnboardingResult(engine)); + } else { + addCallback(this, makeResult(queryContext, engine)); + } + continue; + } + + // Otherwise it may be a partial match that would not be autofilled. + if (host.includes("." + searchStr.toLocaleLowerCase())) { + partialMatchEnginesByHost.set(engine.searchUrlDomain, engine); + // Don't continue here, we are looking for more partial matches. + } + // We also try to match the searchForm domain, because otherwise for an + // engine like ebay, we'd check rover.ebay.com, when the user is likely + // to visit ebay.LANG. The searchForm URL often points to the main host. + let searchFormHost; + try { + searchFormHost = new URL(engine.searchForm).host; + } catch (ex) { + // Invalid url or no searchForm. + } + if (searchFormHost?.includes("." + searchStr)) { + partialMatchEnginesByHost.set(searchFormHost, engine); + } + } + if (partialMatchEnginesByHost.size) { + let host = await lazy.UrlbarProviderAutofill.getTopHostOverThreshold( + queryContext, + Array.from(partialMatchEnginesByHost.keys()) + ); + if (host) { + let engine = partialMatchEnginesByHost.get(host); + if (onboardingInteractionsLeft > 0) { + addCallback(this, makeOnboardingResult(engine, true)); + } else { + addCallback(this, makeResult(queryContext, engine, true)); + } + } + } + } +} + +function makeOnboardingResult(engine, satisfiesAutofillThreshold = false) { + let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: engine.name, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS, + dynamicType: DYNAMIC_RESULT_TYPE, + satisfiesAutofillThreshold, + } + ); + result.resultSpan = 2; + result.suggestedIndex = 1; + return result; +} + +function makeResult(context, engine, satisfiesAutofillThreshold = false) { + let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(context.tokens, { + engine: engine.name, + isGeneralPurposeEngine: engine.isGeneralPurposeEngine, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS, + query: "", + satisfiesAutofillThreshold, + }) + ); + result.suggestedIndex = 1; + return result; +} + +export var UrlbarProviderTabToSearch = new ProviderTabToSearch(); +initializeDynamicResult(); diff --git a/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs new file mode 100644 index 0000000000..e3a1b1c763 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.sys.mjs @@ -0,0 +1,234 @@ +/* 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/. */ + +/** + * This module exports a provider that offers token alias engines. + */ + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +/** + * Class used to create the provider. + */ +class ProviderTokenAliasEngines extends UrlbarProvider { + constructor() { + super(); + this._engines = []; + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "TokenAliasEngines"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + get PRIORITY() { + // Beats UrlbarProviderSearchSuggestions and UrlbarProviderPlaces. + return 1; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + async isActive(queryContext) { + let instance = this.queryInstance; + + // This is usually reset on canceling or completing the query, but since we + // query in isActive, it may not have been canceled by the previous call. + // It is an object with values { result: UrlbarResult, instance: Query }. + this._autofillData = null; + + // Once the user starts typing a search string after the token, we hand off + // suggestions to UrlbarProviderSearchSuggestions. + if ( + !queryContext.searchString.startsWith("@") || + queryContext.tokens.length != 1 + ) { + return false; + } + + // Do not show token alias results in search mode. + if (queryContext.searchMode) { + return false; + } + + this._engines = await lazy.UrlbarSearchUtils.tokenAliasEngines(); + if (!this._engines.length) { + return false; + } + + // Check the query was not canceled while this executed. + if (instance != this.queryInstance) { + return false; + } + + if (queryContext.trimmedSearchString == "@") { + return true; + } + + // If the user is typing a potential engine name, autofill it. + if (lazy.UrlbarPrefs.get("autoFill") && queryContext.allowAutofill) { + let result = this._getAutofillResult(queryContext); + if (result) { + this._autofillData = { result, instance }; + return true; + } + } + + return false; + } + + /** + * Starts querying. + * + * @param {object} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. + */ + async startQuery(queryContext, addCallback) { + if (!this._engines || !this._engines.length) { + return; + } + + if ( + this._autofillData && + this._autofillData.instance == this.queryInstance + ) { + addCallback(this, this._autofillData.result); + } + + for (let { engine, tokenAliases } of this._engines) { + if ( + tokenAliases[0].startsWith(queryContext.trimmedSearchString) && + engine.name != this._autofillData?.result.payload.engine + ) { + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [tokenAliases[0], UrlbarUtils.HIGHLIGHT.TYPED], + query: ["", UrlbarUtils.HIGHLIGHT.TYPED], + icon: engine.iconURI?.spec, + providesSearchMode: true, + }) + ); + addCallback(this, result); + } + } + + this._autofillData = null; + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return this.PRIORITY; + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._autofillData?.instance == this.queryInstance) { + this._autofillData = null; + } + } + + _getAutofillResult(queryContext) { + let lowerCaseSearchString = queryContext.searchString.toLowerCase(); + + // The user is typing a specific engine. We should show a heuristic result. + for (let { engine, tokenAliases } of this._engines) { + for (let alias of tokenAliases) { + if (alias.startsWith(lowerCaseSearchString)) { + // We found the engine. + + // Stop adding an autofill result once the user has typed the full + // alias followed by a space. We enter search mode at that point. + if ( + lowerCaseSearchString.startsWith(alias) && + lazy.UrlbarTokenizer.REGEXP_SPACES_START.test( + lowerCaseSearchString.substring(alias.length) + ) + ) { + return null; + } + + // Add an autofill result. Append a space so the user can hit enter + // or the right arrow key and immediately start typing their query. + let aliasPreservingUserCase = + queryContext.searchString + + alias.substr(queryContext.searchString.length); + let value = aliasPreservingUserCase + " "; + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { + engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [aliasPreservingUserCase, UrlbarUtils.HIGHLIGHT.TYPED], + query: ["", UrlbarUtils.HIGHLIGHT.TYPED], + icon: engine.iconURI?.spec, + providesSearchMode: true, + } + ) + ); + + // We set suggestedIndex = 0 instead of the heuristic because we + // don't want this result to be automatically selected. That way, + // users can press Tab to select the result, building on their + // muscle memory from tab-to-search. + result.suggestedIndex = 0; + + result.autofill = { + value, + selectionStart: queryContext.searchString.length, + selectionEnd: value.length, + }; + return result; + } + } + } + return null; + } +} + +export var UrlbarProviderTokenAliasEngines = new ProviderTokenAliasEngines(); diff --git a/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs b/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs new file mode 100644 index 0000000000..50b3233695 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs @@ -0,0 +1,384 @@ +/* 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/. */ + +/** + * This module exports a provider returning the user's newtab Top Sites. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.sys.mjs", + TOP_SITES_MAX_SITES_PER_ROW: + "resource://activity-stream/common/Reducers.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", +}); + +// The scalar category of TopSites impression for Contextual Services +const SCALAR_CATEGORY_TOPSITES = "contextual.services.topsites.impression"; + +// These prefs must be true for the provider to return results. They are assumed +// to be booleans. We check `system.topsites` because if it is disabled we would +// get stale or empty top sites data. +const TOP_SITES_ENABLED_PREFS = [ + "browser.urlbar.suggest.topsites", + "browser.newtabpage.activity-stream.feeds.system.topsites", +]; + +/** + * A provider that returns the Top Sites shown on about:newtab. + */ +class ProviderTopSites extends UrlbarProvider { + constructor() { + super(); + + this._topSitesListeners = []; + let callListeners = () => this._callTopSitesListeners(); + Services.obs.addObserver(callListeners, "newtab-top-sites-changed"); + for (let pref of TOP_SITES_ENABLED_PREFS) { + Services.prefs.addObserver(pref, callListeners); + } + } + + get PRIORITY() { + // Top sites are prioritized over the UrlbarProviderPlaces provider. + return 1; + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + * + * @returns {string} + */ + get name() { + return "UrlbarProviderTopSites"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + return ( + !queryContext.restrictSource && + !queryContext.searchString && + !queryContext.searchMode + ); + } + + /** + * Gets the provider's priority. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + */ + getPriority(queryContext) { + return this.PRIORITY; + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + // Bail if Top Sites are not enabled. We check this condition here instead + // of in isActive because we still want this provider to be restricting even + // if this is not true. If it wasn't restricting, we would show the results + // from UrlbarProviderPlaces's empty search behaviour. We aren't interested + // in those since they are very similar to Top Sites and thus might be + // confusing, especially since users can configure Top Sites but cannot + // configure the default empty search results. See bug 1623666. + let enabled = TOP_SITES_ENABLED_PREFS.every(p => + Services.prefs.getBoolPref(p, false) + ); + if (!enabled) { + return; + } + + let sites = lazy.AboutNewTab.getTopSites(); + + let instance = this.queryInstance; + + // Filter out empty values. Site is empty when there's a gap between tiles + // on about:newtab. + sites = sites.filter(site => site); + + if (!lazy.UrlbarPrefs.get("sponsoredTopSites")) { + sites = sites.filter(site => !site.sponsored_position); + } + + // This is done here, rather than in the global scope, because + // TOP_SITES_DEFAULT_ROWS causes the import of Reducers.jsm, and we want to + // do that only when actually querying for Top Sites. + if (this.topSitesRows === undefined) { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "topSitesRows", + "browser.newtabpage.activity-stream.topSitesRows", + lazy.TOP_SITES_DEFAULT_ROWS + ); + } + + // We usually respect maxRichResults, though we never show a number of Top + // Sites greater than what is visible in the New Tab Page, because the + // additional ones couldn't be managed from the page. + let numTopSites = Math.min( + lazy.UrlbarPrefs.get("maxRichResults"), + lazy.TOP_SITES_MAX_SITES_PER_ROW * this.topSitesRows + ); + sites = sites.slice(0, numTopSites); + + let sponsoredSites = []; + let index = 1; + sites = sites.map(link => { + let site = { + type: link.searchTopSite ? "search" : "url", + url: link.url_urlbar || link.url, + isPinned: !!link.isPinned, + isSponsored: !!link.sponsored_position, + // The newtab page allows the user to set custom site titles, which + // are stored in `label`, so prefer it. Search top sites currently + // don't have titles but `hostname` instead. + title: link.label || link.title || link.hostname || "", + favicon: link.smallFavicon || link.favicon || undefined, + sendAttributionRequest: !!link.sendAttributionRequest, + }; + if (site.isSponsored) { + let { + sponsored_tile_id, + sponsored_impression_url, + sponsored_click_url, + } = link; + site = { + ...site, + sponsoredTileId: sponsored_tile_id, + sponsoredImpressionUrl: sponsored_impression_url, + sponsoredClickUrl: sponsored_click_url, + position: index, + }; + sponsoredSites.push(site); + } + index++; + return site; + }); + + // Store Sponsored Top Sites so we can use it in `onEngagement` + if (sponsoredSites.length) { + this.sponsoredSites = sponsoredSites; + } + + for (let site of sites) { + switch (site.type) { + case "url": { + let payload = { + title: site.title, + url: site.url, + icon: site.favicon, + isPinned: site.isPinned, + isSponsored: site.isSponsored, + sendAttributionRequest: site.sendAttributionRequest, + }; + if (site.isSponsored) { + payload = { + ...payload, + sponsoredTileId: site.sponsoredTileId, + sponsoredClickUrl: site.sponsoredClickUrl, + }; + } + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + + let tabs; + if (lazy.UrlbarPrefs.get("suggest.openpage")) { + tabs = lazy.UrlbarProviderOpenTabs.getOpenTabs( + queryContext.userContextId || 0, + queryContext.isPrivate + ); + } + + if (tabs && tabs.includes(site.url.replace(/#.*$/, ""))) { + result.type = UrlbarUtils.RESULT_TYPE.TAB_SWITCH; + result.source = UrlbarUtils.RESULT_SOURCE.TABS; + } else if (lazy.UrlbarPrefs.get("suggest.bookmark")) { + let bookmark = await lazy.PlacesUtils.bookmarks.fetch({ + url: new URL(result.payload.url), + }); + if (bookmark) { + result.source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; + } + } + + // Our query has been cancelled. + if (instance != this.queryInstance) { + break; + } + + addCallback(this, result); + break; + } + case "search": { + let engine = await lazy.UrlbarSearchUtils.engineForAlias(site.title); + + if (!engine && site.url) { + // Look up the engine by its domain. + let host; + try { + host = new URL(site.url).hostname; + } catch (err) {} + if (host) { + engine = ( + await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host) + )[0]; + } + } + + if (!engine) { + // No engine found. We skip this Top Site. + break; + } + + if (instance != this.queryInstance) { + break; + } + + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + { + title: site.title, + keyword: site.title, + providesSearchMode: true, + engine: engine.name, + query: "", + icon: site.favicon, + isPinned: site.isPinned, + } + ) + ); + addCallback(this, result); + break; + } + default: + this.logger.error(`Unknown Top Site type: ${site.type}`); + break; + } + } + } + + /** + * Called when the user starts and ends an engagement with the urlbar. We send + * the impression ping for the sponsored TopSites, the impression scalar is + * recorded as well. + * + * Note: + * No telemetry recording in private browsing mode + * The impression is only recorded for the "engagement" and "abandonment" + * states + * + * @param {boolean} isPrivate True if the engagement is in a private context. + * @param {string} state The state of the engagement, one of: start, + * engagement, abandonment, discard. + */ + onEngagement(isPrivate, state) { + if ( + !isPrivate && + this.sponsoredSites && + ["engagement", "abandonment"].includes(state) + ) { + for (let site of this.sponsoredSites) { + Services.telemetry.keyedScalarAdd( + SCALAR_CATEGORY_TOPSITES, + `urlbar_${site.position}`, + 1 + ); + lazy.PartnerLinkAttribution.sendContextualServicesPing( + { + source: "urlbar", + tile_id: site.sponsoredTileId || -1, + position: site.position, + reporting_url: site.sponsoredImpressionUrl, + advertiser: site.title.toLocaleLowerCase(), + }, + lazy.CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_IMPRESSION + ); + } + } + + this.sponsoredSites = null; + } + + /** + * Adds a listener function that will be called when the top sites change or + * they are enabled/disabled. This class will hold a weak reference to the + * listener, so you do not need to unregister it, but you or someone else must + * keep a strong reference to it to keep it from being immediately garbage + * collected. + * + * @param {Function} callback + * The listener function. This class will hold a weak reference to it. + */ + addTopSitesListener(callback) { + this._topSitesListeners.push(Cu.getWeakReference(callback)); + } + + _callTopSitesListeners() { + for (let i = 0; i < this._topSitesListeners.length; ) { + let listener = this._topSitesListeners[i].get(); + if (!listener) { + // The listener has been GC'ed, so remove it from our list. + this._topSitesListeners.splice(i, 1); + } else { + listener(); + ++i; + } + } + } +} + +export var UrlbarProviderTopSites = new ProviderTopSites(); diff --git a/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs b/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs new file mode 100644 index 0000000000..3ee4ec38c6 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderUnitConversion.sys.mjs @@ -0,0 +1,183 @@ +/* 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/. */ + +/** + * Provide unit converter. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { UnitConverterSimple } from "resource:///modules/UnitConverterSimple.sys.mjs"; +import { UnitConverterTemperature } from "resource:///modules/UnitConverterTemperature.sys.mjs"; +import { UnitConverterTimezone } from "resource:///modules/UnitConverterTimezone.sys.mjs"; +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +const CONVERTERS = [ + new UnitConverterSimple(), + new UnitConverterTemperature(), + new UnitConverterTimezone(), +]; + +const DYNAMIC_RESULT_TYPE = "unitConversion"; +const VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "content", + tag: "span", + classList: ["urlbarView-no-wrap"], + children: [ + { + name: "icon", + tag: "img", + classList: ["urlbarView-favicon"], + attributes: { + src: "chrome://global/skin/icons/edit-copy.svg", + }, + }, + { + name: "output", + tag: "strong", + }, + { + name: "action", + tag: "span", + }, + ], + }, + ], +}; + +/** + * Provide a feature that converts given units. + */ +class ProviderUnitConversion extends UrlbarProvider { + constructor() { + super(); + lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "UnitConversion"; + } + + /** + * Returns the type of this provider. + * + * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.* + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + + /** + * Whether the provider should be invoked for the given context. If this + * method returns false, the providers manager won't start a query with this + * provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @returns {boolean} + * Whether this provider should be invoked for the search. + */ + isActive({ searchString }) { + if (!lazy.UrlbarPrefs.get("unitConversion.enabled")) { + return false; + } + + for (const converter of CONVERTERS) { + const result = converter.convert(searchString); + if (result) { + this._activeResult = result; + return true; + } + } + + this._activeResult = null; + return false; + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. + * + * @param {UrlbarResult} result The result whose view will be updated. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result) { + return { + output: { + textContent: result.payload.output, + }, + action: { + l10n: { id: "urlbar-result-action-copy-to-clipboard" }, + }, + }; + } + + /** + * This method is called by the providers manager when a query starts to fetch + * each extension provider's results. It fires the resultsRequested event. + * + * @param {UrlbarQueryContext} queryContext + * The query context object. + * @param {Function} addCallback + * The callback invoked by this method to add each result. + */ + startQuery(queryContext, addCallback) { + const result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: DYNAMIC_RESULT_TYPE, + output: this._activeResult, + input: queryContext.searchString, + } + ); + result.suggestedIndex = lazy.UrlbarPrefs.get( + "unitConversion.suggestedIndex" + ); + + addCallback(this, result); + } + + onEngagement(isPrivate, state, queryContext, details) { + let { result, element } = details; + if (result?.providerName == this.name) { + const { textContent } = element.querySelector( + ".urlbarView-dynamic-unitConversion-output" + ); + lazy.ClipboardHelper.copyString(textContent); + } + } +} + +export const UrlbarProviderUnitConversion = new ProviderUnitConversion(); diff --git a/browser/components/urlbar/UrlbarProviderWeather.sys.mjs b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs new file mode 100644 index 0000000000..b67fa61645 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderWeather.sys.mjs @@ -0,0 +1,590 @@ +/* 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 { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather"; + +const TELEMETRY_PREFIX = "contextual.services.quicksuggest"; + +const TELEMETRY_SCALARS = { + BLOCK: `${TELEMETRY_PREFIX}.block_weather`, + CLICK: `${TELEMETRY_PREFIX}.click_weather`, + HELP: `${TELEMETRY_PREFIX}.help_weather`, + IMPRESSION: `${TELEMETRY_PREFIX}.impression_weather`, +}; + +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_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 provider that returns a suggested url to the user based on what + * they have currently typed so they can navigate directly. + */ +class ProviderWeather extends UrlbarProvider { + constructor(...args) { + super(...args); + lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE); + lazy.UrlbarView.addDynamicViewTemplate( + WEATHER_DYNAMIC_TYPE, + WEATHER_VIEW_TEMPLATE + ); + } + + /** + * Returns the name of this provider. + * + * @returns {string} the name of this provider. + */ + get name() { + return "Weather"; + } + + /** + * The type of the provider. + * + * @returns {UrlbarUtils.PROVIDER_TYPE} + */ + get type() { + return UrlbarUtils.PROVIDER_TYPE.NETWORK; + } + + /** + * @returns {object} An object mapping from mnemonics to scalar names. + */ + get TELEMETRY_SCALARS() { + return { ...TELEMETRY_SCALARS }; + } + + getPriority(context) { + if (!context.searchString) { + // Zero-prefix suggestions have the same priority as top sites. + return lazy.UrlbarProviderTopSites.PRIORITY; + } + return super.getPriority(context); + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + */ + isActive(queryContext) { + this.#resultFromLastQuery = null; + + // If the sources don't include search or the user used a restriction + // character other than search, don't allow any suggestions. + if ( + !queryContext.sources.includes(UrlbarUtils.RESULT_SOURCE.SEARCH) || + (queryContext.restrictSource && + queryContext.restrictSource != UrlbarUtils.RESULT_SOURCE.SEARCH) + ) { + return false; + } + + if ( + queryContext.isPrivate || + queryContext.searchMode || + // `QuickSuggest.weather` will be undefined if `QuickSuggest` hasn't been + // initialized. + !lazy.QuickSuggest.weather?.suggestion + ) { + return false; + } + + let { keywords } = lazy.QuickSuggest.weather; + if (!keywords) { + return false; + } + + return keywords.has(queryContext.searchString.trim().toLocaleLowerCase()); + } + + /** + * Starts querying. Extended classes should return a Promise resolved when the + * provider is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @returns {Promise} + */ + async startQuery(queryContext, addCallback) { + let { suggestion } = lazy.QuickSuggest.weather; + if (!suggestion) { + return; + } + + let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + let result = new lazy.UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + url: suggestion.url, + iconId: suggestion.current_conditions.icon_id, + helpUrl: lazy.QuickSuggest.HELP_URL, + // TODO: Remove helpL10n, isBlockable, and blockL10n once the telemetry + // test is updated for the result menu. + helpL10n: { + id: lazy.UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: lazy.UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + requestId: suggestion.request_id, + source: suggestion.source, + merinoProvider: suggestion.provider, + 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, + } + ); + + result.showFeedbackMenu = true; + result.suggestedIndex = queryContext.searchString ? 1 : 0; + + addCallback(this, result); + this.#resultFromLastQuery = result; + } + + getResultCommands(result) { + let commands = [ + { + name: RESULT_MENU_COMMAND.INACCURATE_LOCATION, + l10n: { + id: "firefox-suggest-weather-command-inaccurate-location", + }, + }, + ]; + + if (lazy.QuickSuggest.weather.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; + } + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update. + * + * @param {UrlbarResult} result + * The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser.This is useful if parts of the view + * update depend on element IDs, as some ARIA attributes do. + * @returns {object} An object describing the view update. + */ + getViewUpdate(result, idsByName) { + 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, + }, + }, + }; + } + + onEngagement(isPrivate, state, queryContext, details) { + // Ignore engagements on other results that didn't end the session. + if (details.result?.providerName != this.name && details.isSessionOngoing) { + return; + } + + // Impression and clicked telemetry are both recorded on engagement. We + // define "impression" to mean a weather result was present in the view when + // any result was picked. + if (state == "engagement" && queryContext) { + // Get the result that's visible in the view. `details.result` is the + // engaged result, if any; if it's from this provider, then that's the + // visible result. Otherwise fall back to #getVisibleResultFromLastQuery. + let { result } = details; + if (result?.providerName != this.name) { + result = this.#getVisibleResultFromLastQuery(queryContext.view); + } + + if (result) { + this.#recordEngagementTelemetry( + result, + isPrivate, + details.result == result ? details.selType : "" + ); + } + } + + // Handle commands. + if (details.result?.providerName == this.name) { + this.#handlePossibleCommand( + queryContext, + details.result, + details.selType + ); + } + + this.#resultFromLastQuery = null; + } + + #getVisibleResultFromLastQuery(view) { + let result = this.#resultFromLastQuery; + + if ( + result?.rowIndex >= 0 && + view?.visibleResults?.[result.rowIndex] == result + ) { + // The result was visible. + return result; + } + + // Find a visible result. + return view?.visibleResults?.find(r => r.providerName == this.name); + } + + /** + * Records engagement telemetry. This should be called only at the end of an + * engagement when a weather result is present or when a weather result is + * dismissed. + * + * @param {UrlbarResult} result + * The weather result that was present (and possibly picked) at the end of + * the engagement or that was dismissed. + * @param {boolean} isPrivate + * Whether the engagement is in a private context. + * @param {string} selType + * This parameter indicates the part of the row the user picked, if any, and + * should be one of the following values: + * + * - "": The user didn't pick the row or any part of it + * - "weather": The user picked the main part of the row + * - "help": The user picked the help button + * - "dismiss": The user dismissed the result + * + * An empty string means the user picked some other row to end the + * engagement, not the weather row. In that case only impression telemetry + * will be recorded. + * + * A non-empty string means the user picked the weather row or some part of + * it, and both impression and click telemetry will be recorded. The + * non-empty-string values come from the `details.selType` passed in to + * `onEngagement()`; see `TelemetryEvent.typeFromElement()`. + */ + #recordEngagementTelemetry(result, isPrivate, selType) { + // Indexes recorded in quick suggest telemetry are 1-based, so add 1 to the + // 0-based `result.rowIndex`. + let telemetryResultIndex = result.rowIndex + 1; + + // impression scalars + Services.telemetry.keyedScalarAdd( + TELEMETRY_SCALARS.IMPRESSION, + telemetryResultIndex, + 1 + ); + + // scalars related to clicking the result and other elements in its row + let clickScalars = []; + let eventObject; + switch (selType) { + case "weather": + clickScalars.push(TELEMETRY_SCALARS.CLICK); + eventObject = "click"; + break; + case "help": + clickScalars.push(TELEMETRY_SCALARS.HELP); + eventObject = "help"; + break; + case "dismiss": + clickScalars.push(TELEMETRY_SCALARS.BLOCK); + eventObject = "block"; + break; + default: + if (selType) { + eventObject = "other"; + } + break; + } + for (let scalar of clickScalars) { + Services.telemetry.keyedScalarAdd(scalar, telemetryResultIndex, 1); + } + + // engagement event + Services.telemetry.recordEvent( + lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + "engagement", + eventObject || "impression_only", + "", + { + match_type: "firefox-suggest", + position: String(telemetryResultIndex), + suggestion_type: "weather", + source: result.payload.source, + } + ); + } + + #handlePossibleCommand(queryContext, 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); + queryContext.view.acknowledgeDismissal(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`. + queryContext.view.acknowledgeFeedback(result); + break; + case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: + queryContext.view.acknowledgeFeedback(result); + lazy.QuickSuggest.weather.incrementMinKeywordLength(); + if (!lazy.QuickSuggest.weather.canIncrementMinKeywordLength) { + queryContext.view.invalidateResultMenuCommands(); + } + break; + } + } + + // The result we added during the most recent query. + #resultFromLastQuery = null; +} + +export var UrlbarProviderWeather = new ProviderWeather(); diff --git a/browser/components/urlbar/UrlbarProvidersManager.sys.mjs b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs new file mode 100644 index 0000000000..c64234753e --- /dev/null +++ b/browser/components/urlbar/UrlbarProvidersManager.sys.mjs @@ -0,0 +1,784 @@ +/* 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/. */ + +/** + * This module exports a component used to register search providers and manage + * the connection between such providers and a UrlbarController. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + SkippableTimer: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarMuxer: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: "ProvidersManager" }) +); + +// List of available local providers, each is implemented in its own jsm module +// and will track different queries internally by queryContext. +// When adding new providers please remember to update the list in metrics.yaml. +var localProviderModules = { + UrlbarProviderAboutPages: + "resource:///modules/UrlbarProviderAboutPages.sys.mjs", + UrlbarProviderAliasEngines: + "resource:///modules/UrlbarProviderAliasEngines.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarProviderBookmarkKeywords: + "resource:///modules/UrlbarProviderBookmarkKeywords.sys.mjs", + UrlbarProviderCalculator: + "resource:///modules/UrlbarProviderCalculator.sys.mjs", + UrlbarProviderContextualSearch: + "resource:///modules/UrlbarProviderContextualSearch.sys.mjs", + UrlbarProviderHeuristicFallback: + "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs", + UrlbarProviderHistoryUrlHeuristic: + "resource:///modules/UrlbarProviderHistoryUrlHeuristic.sys.mjs", + UrlbarProviderInputHistory: + "resource:///modules/UrlbarProviderInputHistory.sys.mjs", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.sys.mjs", + UrlbarProviderOmnibox: "resource:///modules/UrlbarProviderOmnibox.sys.mjs", + UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs", + UrlbarProviderPreloadedSites: + "resource:///modules/UrlbarProviderPreloadedSites.sys.mjs", + UrlbarProviderPrivateSearch: + "resource:///modules/UrlbarProviderPrivateSearch.sys.mjs", + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProviderRemoteTabs: + "resource:///modules/UrlbarProviderRemoteTabs.sys.mjs", + UrlbarProviderSearchTips: + "resource:///modules/UrlbarProviderSearchTips.sys.mjs", + UrlbarProviderSearchSuggestions: + "resource:///modules/UrlbarProviderSearchSuggestions.sys.mjs", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", + UrlbarProviderTokenAliasEngines: + "resource:///modules/UrlbarProviderTokenAliasEngines.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarProviderUnitConversion: + "resource:///modules/UrlbarProviderUnitConversion.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}; + +// List of available local muxers, each is implemented in its own jsm module. +var localMuxerModules = { + UrlbarMuxerUnifiedComplete: + "resource:///modules/UrlbarMuxerUnifiedComplete.sys.mjs", +}; + +// To improve dataflow and reduce UI work, when a result is added by a +// non-heuristic provider, we notify it to the controller after a delay, so +// that we can chunk results coming in that timeframe into a single call. +const CHUNK_RESULTS_DELAY_MS = 16; + +const DEFAULT_MUXER = "UnifiedComplete"; + +/** + * Class used to create a manager. + * The manager is responsible to keep a list of providers, instantiate query + * objects and pass those to the providers. + */ +class ProvidersManager { + constructor() { + // Tracks the available providers. This is a sorted array, with HEURISTIC + // providers at the front. + this.providers = []; + for (let [symbol, module] of Object.entries(localProviderModules)) { + let { [symbol]: provider } = ChromeUtils.importESModule(module); + this.registerProvider(provider); + } + // Tracks ongoing Query instances by queryContext. + this.queries = new Map(); + + // Interrupt() allows to stop any running SQL query, some provider may be + // running a query that shouldn't be interrupted, and if so it should + // bump this through disableInterrupt and enableInterrupt. + this.interruptLevel = 0; + + // This maps muxer names to muxers. + this.muxers = new Map(); + for (let [symbol, module] of Object.entries(localMuxerModules)) { + let { [symbol]: muxer } = ChromeUtils.importESModule(module); + this.registerMuxer(muxer); + } + + // This is defined as a property so tests can override it. + this._chunkResultsDelayMs = CHUNK_RESULTS_DELAY_MS; + } + + /** + * Registers a provider object with the manager. + * + * @param {object} provider + * The provider object to register. + */ + registerProvider(provider) { + if (!provider || !(provider instanceof lazy.UrlbarProvider)) { + throw new Error(`Trying to register an invalid provider`); + } + if ( + !Object.values(lazy.UrlbarUtils.PROVIDER_TYPE).includes(provider.type) + ) { + throw new Error(`Unknown provider type ${provider.type}`); + } + lazy.logger.info(`Registering provider ${provider.name}`); + let index = -1; + if (provider.type == lazy.UrlbarUtils.PROVIDER_TYPE.HEURISTIC) { + // Keep heuristic providers in order at the front of the array. Find the + // first non-heuristic provider and insert the new provider there. + index = this.providers.findIndex( + p => p.type != lazy.UrlbarUtils.PROVIDER_TYPE.HEURISTIC + ); + } + if (index < 0) { + index = this.providers.length; + } + this.providers.splice(index, 0, provider); + } + + /** + * Unregisters a previously registered provider object. + * + * @param {object} provider + * The provider object to unregister. + */ + unregisterProvider(provider) { + lazy.logger.info(`Unregistering provider ${provider.name}`); + let index = this.providers.findIndex(p => p.name == provider.name); + if (index != -1) { + this.providers.splice(index, 1); + } + } + + /** + * Returns the provider with the given name. + * + * @param {string} name + * The provider name. + * @returns {UrlbarProvider} The provider. + */ + getProvider(name) { + return this.providers.find(p => p.name == name); + } + + /** + * Registers a muxer object with the manager. + * + * @param {object} muxer + * a UrlbarMuxer object + */ + registerMuxer(muxer) { + if (!muxer || !(muxer instanceof lazy.UrlbarMuxer)) { + throw new Error(`Trying to register an invalid muxer`); + } + lazy.logger.info(`Registering muxer ${muxer.name}`); + this.muxers.set(muxer.name, muxer); + } + + /** + * Unregisters a previously registered muxer object. + * + * @param {object} muxer + * a UrlbarMuxer object or name. + */ + unregisterMuxer(muxer) { + let muxerName = typeof muxer == "string" ? muxer : muxer.name; + lazy.logger.info(`Unregistering muxer ${muxerName}`); + this.muxers.delete(muxerName); + } + + /** + * Starts querying. + * + * @param {object} queryContext + * The query context object + * @param {object} [controller] + * a UrlbarController instance + */ + async startQuery(queryContext, controller = null) { + lazy.logger.info(`Query start "${queryContext.searchString}"`); + + // Define the muxer to use. + let muxerName = queryContext.muxer || DEFAULT_MUXER; + lazy.logger.debug(`Using muxer ${muxerName}`); + let muxer = this.muxers.get(muxerName); + if (!muxer) { + throw new Error(`Muxer with name ${muxerName} not found`); + } + + // If the queryContext specifies a list of providers to use, filter on it, + // otherwise just pass the full list of providers. + let providers = queryContext.providers + ? this.providers.filter(p => queryContext.providers.includes(p.name)) + : this.providers; + + // Apply tokenization. + lazy.UrlbarTokenizer.tokenize(queryContext); + + // If there's a single source, we are in restriction mode. + if (queryContext.sources && queryContext.sources.length == 1) { + queryContext.restrictSource = queryContext.sources[0]; + } + // Providers can use queryContext.sources to decide whether they want to be + // invoked or not. + // The sources may be defined in the context, then the whole search string + // can be used for searching. Otherwise sources are extracted from prefs and + // restriction tokens, then restriction tokens must be filtered out of the + // search string. + let restrictToken = updateSourcesIfEmpty(queryContext); + if (restrictToken) { + queryContext.restrictToken = restrictToken; + // If the restriction token has an equivalent source, then set it as + // restrictSource. + if (lazy.UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(restrictToken.value)) { + queryContext.restrictSource = queryContext.sources[0]; + } + } + lazy.logger.debug(`Context sources ${queryContext.sources}`); + + let query = new Query(queryContext, controller, muxer, providers); + this.queries.set(queryContext, query); + + // The muxer and many providers depend on the search service and our search + // utils. Make sure they're initialized now (via UrlbarSearchUtils) so that + // all query-related urlbar modules don't need to do it. + try { + await lazy.UrlbarSearchUtils.init(); + } catch { + // We continue anyway, because we want the user to be able to search their + // history and bookmarks even if search engines are not available. + } + + if (query.canceled) { + return; + } + + // Update the behavior of extension providers. + let updateBehaviorPromises = []; + for (let provider of this.providers) { + if ( + provider.type == lazy.UrlbarUtils.PROVIDER_TYPE.EXTENSION && + provider.name != "Omnibox" + ) { + updateBehaviorPromises.push( + provider.tryMethod("updateBehavior", queryContext) + ); + } + } + if (updateBehaviorPromises.length) { + await Promise.all(updateBehaviorPromises); + if (query.canceled) { + return; + } + } + + await query.start(); + } + + /** + * Cancels a running query. + * + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + lazy.logger.info(`Query cancel "${queryContext.searchString}"`); + let query = this.queries.get(queryContext); + if (!query) { + throw new Error("Couldn't find a matching query for the given context"); + } + query.cancel(); + if (!this.interruptLevel) { + try { + let db = lazy.PlacesUtils.promiseLargeCacheDBConnection(); + db.interrupt(); + } catch (ex) {} + } + this.queries.delete(queryContext); + } + + /** + * A provider can use this util when it needs to run a SQL query that can't + * be interrupted. Otherwise, when a query is canceled any running SQL query + * is interrupted abruptly. + * + * @param {Function} taskFn a Task to execute in the critical section. + */ + async runInCriticalSection(taskFn) { + this.interruptLevel++; + try { + await taskFn(); + } finally { + this.interruptLevel--; + } + } + + /** + * Notifies all providers when the user starts and ends an engagement with the + * urlbar. For details on parameters, see UrlbarProvider.onEngagement(). + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of: start, engagement, abandonment, + * discard + * @param {UrlbarQueryContext} queryContext + * The engagement's query context, if available. + * @param {object} details + * An object that describes the search string and the picked result, if any. + * @param {window} window + * Browser window object associated with engagement + */ + notifyEngagementChange(isPrivate, state, queryContext, details = {}, window) { + for (let provider of this.providers) { + provider.tryMethod( + "onEngagement", + isPrivate, + state, + queryContext, + details, + window + ); + } + } +} + +export var UrlbarProvidersManager = new ProvidersManager(); + +/** + * Tracks a query status. + * Multiple queries can potentially be executed at the same time by different + * controllers. Each query has to track its own status and delays separately, + * to avoid conflicting with other ones. + */ +class Query { + /** + * Initializes the query object. + * + * @param {object} queryContext + * The query context + * @param {object} controller + * The controller to be notified + * @param {object} muxer + * The muxer to sort results + * @param {Array} providers + * Array of all the providers. + */ + constructor(queryContext, controller, muxer, providers) { + this.context = queryContext; + this.context.results = []; + // Clear any state in the context object, since it could be reused by the + // caller and we don't want to port previous query state over. + this.context.pendingHeuristicProviders.clear(); + this.context.deferUserSelectionProviders.clear(); + this.unsortedResults = []; + this.muxer = muxer; + this.controller = controller; + this.providers = providers; + this.started = false; + this.canceled = false; + + // This is used as a last safety filter in add(), thus we keep an unmodified + // copy of it. + this.acceptableSources = queryContext.sources.slice(); + } + + /** + * Starts querying. + */ + async start() { + if (this.started) { + throw new Error("This Query has been started already"); + } + this.started = true; + + // Check which providers should be queried by calling isActive on them. + let activeProviders = []; + let activePromises = []; + let maxPriority = -1; + for (let provider of this.providers) { + // This can be used by the provider to check the query is still running + // after executing async tasks: + // let instance = this.queryInstance; + // await ... + // if (instance != this.queryInstance) { + // // Query was canceled or a new one started. + // return; + // } + provider.queryInstance = this; + activePromises.push( + // Not all isActive implementations are async, so wrap the call in a + // promise so we can be sure we can call `then` on it. Note that + // Promise.resolve returns its arg directly if it's already a promise. + Promise.resolve(provider.tryMethod("isActive", this.context)) + .then(isActive => { + if (isActive && !this.canceled) { + let priority = provider.tryMethod("getPriority", this.context); + if (priority >= maxPriority) { + // The provider's priority is at least as high as the max. + if (priority > maxPriority) { + // The provider's priority is higher than the max. Remove all + // previously added providers, since their priority is + // necessarily lower, by setting length to zero. + activeProviders.length = 0; + maxPriority = priority; + } + activeProviders.push(provider); + if (provider.deferUserSelection) { + this.context.deferUserSelectionProviders.add(provider.name); + } + } + } + }) + .catch(ex => lazy.logger.error(ex)) + ); + } + + // We have to wait for all isActive calls to finish because we want to query + // only the highest priority active providers as determined by the priority + // logic above. + await Promise.all(activePromises); + + if (this.canceled) { + this.controller = null; + return; + } + + // Start querying active providers. + let startQuery = async provider => { + provider.logger.debug( + `Starting query for "${this.context.searchString}"` + ); + let addedResult = false; + await provider.tryMethod("startQuery", this.context, (...args) => { + addedResult = true; + this.add(...args); + }); + if (!addedResult) { + this.context.deferUserSelectionProviders.delete(provider.name); + } + }; + + let queryPromises = []; + for (let provider of activeProviders) { + if (provider.type == lazy.UrlbarUtils.PROVIDER_TYPE.HEURISTIC) { + this.context.pendingHeuristicProviders.add(provider.name); + queryPromises.push(startQuery(provider)); + continue; + } + if (!this._sleepTimer) { + // Tracks the delay timer. We will fire (in this specific case, cancel + // would do the same, since the callback is empty) the timer when the + // search is canceled, unblocking start(). + this._sleepTimer = new lazy.SkippableTimer({ + name: "Query provider timer", + time: lazy.UrlbarPrefs.get("delay"), + logger: provider.logger, + }); + } + queryPromises.push( + this._sleepTimer.promise.then(() => + this.canceled ? undefined : startQuery(provider) + ) + ); + } + + lazy.logger.info( + `Queried ${queryPromises.length} providers: ${activeProviders.map( + p => p.name + )}` + ); + await Promise.all(queryPromises); + + // All the providers are done returning results, so we can stop chunking. + if (!this.canceled) { + if (this._heuristicProviderTimer) { + await this._heuristicProviderTimer.fire(); + } + if (this._chunkTimer) { + await this._chunkTimer.fire(); + } + } + + // Break cycles with the controller to avoid leaks. + this.controller = null; + } + + /** + * Cancels this query. Note: Invoking cancel multiple times is a no-op. + */ + cancel() { + if (this.canceled) { + return; + } + this.canceled = true; + this.context.deferUserSelectionProviders.clear(); + for (let provider of this.providers) { + provider.logger.debug( + `Canceling query for "${this.context.searchString}"` + ); + // Mark the instance as no more valid, see start() for details. + provider.queryInstance = null; + provider.tryMethod("cancelQuery", this.context); + } + if (this._heuristicProviderTimer) { + this._heuristicProviderTimer.cancel().catch(ex => lazy.logger.error(ex)); + } + if (this._chunkTimer) { + this._chunkTimer.cancel().catch(ex => lazy.logger.error(ex)); + } + if (this._sleepTimer) { + this._sleepTimer.fire().catch(ex => lazy.logger.error(ex)); + } + } + + /** + * Adds a result returned from a provider to the results set. + * + * @param {UrlbarProvider} provider The provider that returned the result. + * @param {object} result The result object. + */ + add(provider, result) { + if (!(provider instanceof lazy.UrlbarProvider)) { + throw new Error("Invalid provider passed to the add callback"); + } + + // When this set is empty, we can display heuristic results early. We remove + // the provider from the list without checking result.heuristic since + // heuristic providers don't necessarily have to return heuristic results. + // We expect a provider with type HEURISTIC will return its heuristic + // result(s) first. + this.context.pendingHeuristicProviders.delete(provider.name); + + // Stop returning results as soon as we've been canceled. + if (this.canceled) { + return; + } + + // In search mode, don't allow heuristic results in the following cases + // since they don't make sense: + // * When the search string is empty, or + // * In local search mode, except for autofill results + if ( + result.heuristic && + this.context.searchMode && + (!this.context.trimmedSearchString || + (!this.context.searchMode.engineName && !result.autofill)) + ) { + return; + } + + // Check if the result source should be filtered out. Pay attention to the + // heuristic result though, that is supposed to be added regardless. + if ( + !this.acceptableSources.includes(result.source) && + !result.heuristic && + // Treat form history as searches for the purpose of acceptableSources. + (result.type != lazy.UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != lazy.UrlbarUtils.RESULT_SOURCE.HISTORY || + !this.acceptableSources.includes(lazy.UrlbarUtils.RESULT_SOURCE.SEARCH)) + ) { + return; + } + + // Filter out javascript results for safety. The provider is supposed to do + // it, but we don't want to risk leaking these out. + if ( + result.type != lazy.UrlbarUtils.RESULT_TYPE.KEYWORD && + result.payload.url && + result.payload.url.startsWith("javascript:") && + !this.context.searchString.startsWith("javascript:") && + lazy.UrlbarPrefs.get("filter.javascript") + ) { + return; + } + + result.providerName = provider.name; + result.providerType = provider.type; + this.unsortedResults.push(result); + + this._notifyResultsFromProvider(provider); + } + + _notifyResultsFromProvider(provider) { + // We create two chunking timers: one for heuristic results, and one for + // other results. We expect heuristic providers to return their heuristic + // results before other results/providers in most cases. When all heuristic + // providers have returned some results, we fire the heuristic timer early. + // If the timer fires first, we stop waiting on the remaining heuristic + // providers. + // Both timers are used to reduce UI flicker. + if (provider.type == lazy.UrlbarUtils.PROVIDER_TYPE.HEURISTIC) { + if (!this._heuristicProviderTimer) { + this._heuristicProviderTimer = new lazy.SkippableTimer({ + name: "Heuristic provider timer", + callback: () => this._notifyResults(), + time: UrlbarProvidersManager._chunkResultsDelayMs, + logger: provider.logger, + }); + } + } else if (!this._chunkTimer) { + this._chunkTimer = new lazy.SkippableTimer({ + name: "Query chunk timer", + callback: () => this._notifyResults(), + time: UrlbarProvidersManager._chunkResultsDelayMs, + logger: provider.logger, + }); + } + // If all active heuristic providers have returned results, we can skip the + // heuristic results timer and start showing results immediately. + if ( + this._heuristicProviderTimer && + !this.context.pendingHeuristicProviders.size + ) { + this._heuristicProviderTimer.fire().catch(ex => lazy.logger.error(ex)); + } + } + + _notifyResults() { + this.muxer.sort(this.context, this.unsortedResults); + + if (this._heuristicProviderTimer) { + this._heuristicProviderTimer.cancel().catch(ex => lazy.logger.error(ex)); + this._heuristicProviderTimer = null; + } + + if (this._chunkTimer) { + this._chunkTimer.cancel().catch(ex => lazy.logger.error(ex)); + this._chunkTimer = null; + } + + // We don't want to notify consumers if there are no results since they + // generally expect at least one result when notified, so bail, but only + // after nulling out the chunk timer above so that it will be restarted + // the next time results are added. + if (!this.context.results.length) { + return; + } + + this.context.firstResultChanged = !lazy.ObjectUtils.deepEqual( + this.context.firstResult, + this.context.results[0] + ); + this.context.firstResult = this.context.results[0]; + + if (this.controller) { + this.controller.receiveResults(this.context); + } + } +} + +/** + * Updates in place the sources for a given UrlbarQueryContext. + * + * @param {UrlbarQueryContext} context The query context to examine + * @returns {object} The restriction token that was used to set sources, or + * undefined if there's no restriction token. + */ +function updateSourcesIfEmpty(context) { + if (context.sources && context.sources.length) { + return false; + } + let acceptedSources = []; + // There can be only one restrict token per query. + let restrictToken = context.tokens.find(t => + [ + lazy.UrlbarTokenizer.TYPE.RESTRICT_HISTORY, + lazy.UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG, + lazy.UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, + lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE, + lazy.UrlbarTokenizer.TYPE.RESTRICT_URL, + lazy.UrlbarTokenizer.TYPE.RESTRICT_ACTION, + ].includes(t.type) + ); + + // RESTRICT_TITLE and RESTRICT_URL do not affect query sources. + let restrictTokenType = + restrictToken && + restrictToken.type != lazy.UrlbarTokenizer.TYPE.RESTRICT_TITLE && + restrictToken.type != lazy.UrlbarTokenizer.TYPE.RESTRICT_URL + ? restrictToken.type + : undefined; + + for (let source of Object.values(lazy.UrlbarUtils.RESULT_SOURCE)) { + // Skip sources that the context doesn't care about. + if (context.sources && !context.sources.includes(source)) { + continue; + } + // Check prefs and restriction tokens. + switch (source) { + case lazy.UrlbarUtils.RESULT_SOURCE.BOOKMARKS: + if ( + restrictTokenType === lazy.UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK || + restrictTokenType === lazy.UrlbarTokenizer.TYPE.RESTRICT_TAG || + (!restrictTokenType && lazy.UrlbarPrefs.get("suggest.bookmark")) + ) { + acceptedSources.push(source); + } + break; + case lazy.UrlbarUtils.RESULT_SOURCE.HISTORY: + if ( + restrictTokenType === lazy.UrlbarTokenizer.TYPE.RESTRICT_HISTORY || + (!restrictTokenType && lazy.UrlbarPrefs.get("suggest.history")) + ) { + acceptedSources.push(source); + } + break; + case lazy.UrlbarUtils.RESULT_SOURCE.SEARCH: + if ( + restrictTokenType === lazy.UrlbarTokenizer.TYPE.RESTRICT_SEARCH || + !restrictTokenType + ) { + // We didn't check browser.urlbar.suggest.searches here, because it + // just controls search suggestions. If a search suggestion arrives + // here, we lost already, because we broke user's privacy by hitting + // the network. Thus, it's better to leave things go through and + // notice the bug, rather than hiding it with a filter. + acceptedSources.push(source); + } + break; + case lazy.UrlbarUtils.RESULT_SOURCE.TABS: + if ( + restrictTokenType === lazy.UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE || + (!restrictTokenType && lazy.UrlbarPrefs.get("suggest.openpage")) + ) { + acceptedSources.push(source); + } + break; + case lazy.UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK: + if (!context.isPrivate && !restrictTokenType) { + acceptedSources.push(source); + } + break; + case lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL: + case lazy.UrlbarUtils.RESULT_SOURCE.ADDON: + default: + if (!restrictTokenType) { + acceptedSources.push(source); + } + break; + } + } + context.sources = acceptedSources; + return restrictToken; +} diff --git a/browser/components/urlbar/UrlbarResult.sys.mjs b/browser/components/urlbar/UrlbarResult.sys.mjs new file mode 100644 index 0000000000..81f9aa118d --- /dev/null +++ b/browser/components/urlbar/UrlbarResult.sys.mjs @@ -0,0 +1,382 @@ +/* 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/. */ + +/** + * This module exports a urlbar result class, each representing a single result + * found by a provider that can be passed from the model to the view through + * the controller. It is mainly defined by a result type, and a payload, + * containing the data. A few getters allow to retrieve information common to all + * the result types. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", +}); + +/** + * Class used to create a single result. + */ +export class UrlbarResult { + /** + * Creates a result. + * + * @param {integer} resultType one of UrlbarUtils.RESULT_TYPE.* values + * @param {integer} resultSource one of UrlbarUtils.RESULT_SOURCE.* values + * @param {object} payload data for this result. A payload should always + * contain a way to extract a final url to visit. The url getter + * should have a case for each of the types. + * @param {object} [payloadHighlights] payload highlights, if any. Each + * property in the payload may have a corresponding property in this + * object. The value of each property should be an array of [index, + * length] tuples. Each tuple indicates a substring in the corresponding + * payload property. + */ + constructor(resultType, resultSource, payload, payloadHighlights = {}) { + // Type describes the payload and visualization that should be used for + // this result. + if (!Object.values(lazy.UrlbarUtils.RESULT_TYPE).includes(resultType)) { + throw new Error("Invalid result type"); + } + this.type = resultType; + + // Source describes which data has been used to derive this result. In case + // multiple sources are involved, use the more privacy restricted. + if (!Object.values(lazy.UrlbarUtils.RESULT_SOURCE).includes(resultSource)) { + throw new Error("Invalid result source"); + } + this.source = resultSource; + + // UrlbarView is responsible for updating this. + this.rowIndex = -1; + + // May be used to indicate an heuristic result. Heuristic results can bypass + // source filters in the ProvidersManager, that otherwise may skip them. + this.heuristic = false; + + // Exposure specific properties. These allow us to track the exposure + // of a result through the query process. + // A non-zero value here indicates that this result's exposure should be + // recorded in the exposure event. + this.exposureResultType = ""; + + // Determines if the exposure result should be hidden from the view. + this.exposureResultHidden = false; + + // The payload contains result data. Some of the data is common across + // multiple types, but most of it will vary. + if (!payload || typeof payload != "object") { + throw new Error("Invalid result payload"); + } + this.payload = this.validatePayload(payload); + + if (!payloadHighlights || typeof payloadHighlights != "object") { + throw new Error("Invalid result payload highlights"); + } + this.payloadHighlights = payloadHighlights; + + // Make sure every property in the payload has an array of highlights. If a + // payload property does not have a highlights array, then give it one now. + // That way the consumer doesn't need to check whether it exists. + for (let name in payload) { + if (!(name in this.payloadHighlights)) { + this.payloadHighlights[name] = []; + } + } + } + + /** + * Returns a title that could be used as a label for this result. + * + * @returns {string} The label to show in a simplified title / url view. + */ + get title() { + return this._titleAndHighlights[0]; + } + + /** + * Returns an array of highlights for the title. + * + * @returns {Array} The array of highlights. + */ + get titleHighlights() { + return this._titleAndHighlights[1]; + } + + /** + * Returns an array [title, highlights]. + * + * @returns {Array} The title and array of highlights. + */ + get _titleAndHighlights() { + switch (this.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + case lazy.UrlbarUtils.RESULT_TYPE.URL: + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + if (this.payload.qsSuggestion) { + return [ + // We will initially only be targeting en-US users with this experiment + // but will need to change this to work properly with l10n. + this.payload.qsSuggestion + " — " + this.payload.title, + this.payloadHighlights.qsSuggestion, + ]; + } + + if (this.payload.fallbackTitle) { + return [ + this.payload.fallbackTitle, + this.payloadHighlights.fallbackTitle, + ]; + } + + if (this.payload.title) { + return [this.payload.title, this.payloadHighlights.title]; + } + + return [this.payload.url ?? "", this.payloadHighlights.url ?? []]; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + if (this.payload.providesSearchMode) { + return ["", []]; + } + if (this.payload.tail && this.payload.tailOffsetIndex >= 0) { + return [this.payload.tail, this.payloadHighlights.tail]; + } else if (this.payload.suggestion) { + return [this.payload.suggestion, this.payloadHighlights.suggestion]; + } + return [this.payload.query, this.payloadHighlights.query]; + default: + return ["", []]; + } + } + + /** + * Returns an icon url. + * + * @returns {string} url of the icon. + */ + get icon() { + return this.payload.icon; + } + + /** + * Returns whether the result's `suggestedIndex` property is defined. + * `suggestedIndex` is an optional hint to the muxer that can be set to + * suggest a specific position among the results. + * + * @returns {boolean} Whether `suggestedIndex` is defined. + */ + get hasSuggestedIndex() { + return typeof this.suggestedIndex == "number"; + } + + /** + * Returns the given payload if it's valid or throws an error if it's not. + * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation. + * + * @param {object} payload The payload object. + * @returns {object} `payload` if it's valid. + */ + validatePayload(payload) { + let schema = lazy.UrlbarUtils.getPayloadSchema(this.type); + if (!schema) { + throw new Error(`Unrecognized result type: ${this.type}`); + } + let result = lazy.JsonSchemaValidator.validate(payload, schema, { + allowExplicitUndefinedProperties: true, + allowNullAsUndefinedProperties: true, + allowExtraProperties: this.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, + }); + if (!result.valid) { + throw result.error; + } + return payload; + } + + /** + * A convenience function that takes a payload annotated with + * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's + * highlights. Use this function when the highlighting required by your + * payload is based on simple substring matching, as done by + * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and + * `payloadHighlights` params of the UrlbarResult constructor. + * `payloadHighlights` is optional. If omitted, payload will not be + * highlighted. + * + * If the payload doesn't have a title or has an empty title, and it also has + * a URL, then this function also sets the title to the URL's domain. + * + * @param {Array} tokens The tokens that should be highlighted in each of the + * payload properties. + * @param {object} payloadInfo An object that looks like this: + * { payloadPropertyName: payloadPropertyInfo } + * + * Each payloadPropertyInfo may be either a string or an array. If + * it's a string, then the property value will be that string, and no + * highlighting will be applied to it. If it's an array, then it + * should look like this: [payloadPropertyValue, highlightType]. + * payloadPropertyValue may be a string or an array of strings. If + * it's a string, then the payloadHighlights in the return value will + * be an array of match highlights as described in + * UrlbarUtils.getTokenMatches(). If it's an array, then + * payloadHighlights will be an array of arrays of match highlights, + * one element per element in payloadPropertyValue. + * @returns {Array} An array [payload, payloadHighlights]. + */ + static payloadAndSimpleHighlights(tokens, payloadInfo) { + // Convert scalar values in payloadInfo to [value] arrays. + for (let [name, info] of Object.entries(payloadInfo)) { + if (!Array.isArray(info)) { + payloadInfo[name] = [info]; + } + } + + if ( + (!payloadInfo.title || !payloadInfo.title[0]) && + !payloadInfo.fallbackTitle && + payloadInfo.url && + typeof payloadInfo.url[0] == "string" + ) { + // If there's no title, show the domain as the title. Not all valid URLs + // have a domain. + payloadInfo.title = payloadInfo.title || [ + "", + lazy.UrlbarUtils.HIGHLIGHT.TYPED, + ]; + try { + payloadInfo.title[0] = new URL(payloadInfo.url[0]).host; + } catch (e) {} + } + + if (payloadInfo.url) { + // For display purposes we need to unescape the url. + payloadInfo.displayUrl = [...payloadInfo.url]; + let url = payloadInfo.displayUrl[0]; + if (url && lazy.UrlbarPrefs.get("trimURLs")) { + url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url); + if (url.startsWith("https://")) { + url = url.substring(8); + if (url.startsWith("www.")) { + url = url.substring(4); + } + } + } + payloadInfo.displayUrl[0] = lazy.UrlbarUtils.unEscapeURIForUI(url); + } + + // For performance reasons limit excessive string lengths, to reduce the + // amount of string matching we do here, and avoid wasting resources to + // handle long textruns that the user would never see anyway. + for (let prop of ["displayUrl", "title", "suggestion"]) { + let val = payloadInfo[prop]?.[0]; + if (typeof val == "string") { + payloadInfo[prop][0] = val.substring( + 0, + lazy.UrlbarUtils.MAX_TEXT_LENGTH + ); + } + } + + let entries = Object.entries(payloadInfo); + return [ + entries.reduce((payload, [name, [val, _]]) => { + payload[name] = val; + return payload; + }, {}), + entries.reduce((highlights, [name, [val, highlightType]]) => { + if (highlightType) { + highlights[name] = !Array.isArray(val) + ? lazy.UrlbarUtils.getTokenMatches(tokens, val || "", highlightType) + : val.map(subval => + lazy.UrlbarUtils.getTokenMatches(tokens, subval, highlightType) + ); + } + return highlights; + }, {}), + ]; + } + + static _dynamicResultTypesByName = new Map(); + + /** + * Registers a dynamic result type. Dynamic result types are types that are + * created at runtime, for example by an extension. A particular type should + * be added only once; if this method is called for a type more than once, the + * `type` in the last call overrides those in previous calls. + * + * @param {string} name + * The name of the type. This is used in CSS selectors, so it shouldn't + * contain any spaces or punctuation except for -, _, etc. + * @param {object} type + * An object that describes the type. Currently types do not have any + * associated metadata, so this object should be empty. + */ + static addDynamicResultType(name, type = {}) { + if (/[^a-z0-9_-]/i.test(name)) { + this.logger.error(`Illegal dynamic type name: ${name}`); + return; + } + this._dynamicResultTypesByName.set(name, type); + } + + /** + * Unregisters a dynamic result type. + * + * @param {string} name + * The name of the type. + */ + static removeDynamicResultType(name) { + let type = this._dynamicResultTypesByName.get(name); + if (type) { + this._dynamicResultTypesByName.delete(name); + } + } + + /** + * Returns an object describing a registered dynamic result type. + * + * @param {string} name + * The name of the type. + * @returns {object} + * Currently types do not have any associated metadata, so the return value + * is an empty object if the type exists. If the type doesn't exist, + * undefined is returned. + */ + static getDynamicResultType(name) { + return this._dynamicResultTypesByName.get(name); + } + + /** + * This is useful for logging results. If you need the full payload, then it's + * better to JSON.stringify the result object itself. + * + * @returns {string} string representation of the result. + */ + toString() { + if (this.payload.url) { + return this.payload.title + " - " + this.payload.url.substr(0, 100); + } + if (this.payload.keyword) { + return this.payload.keyword + " - " + this.payload.query; + } + if (this.payload.suggestion) { + return this.payload.engine + " - " + this.payload.suggestion; + } + if (this.payload.engine) { + return this.payload.engine + " - " + this.payload.query; + } + return JSON.stringify(this); + } +} diff --git a/browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs b/browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs new file mode 100644 index 0000000000..910430b464 --- /dev/null +++ b/browser/components/urlbar/UrlbarSearchOneOffs.sys.mjs @@ -0,0 +1,399 @@ +/* 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 { SearchOneOffs } from "resource:///modules/SearchOneOffs.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +/** + * The one-off search buttons in the urlbar. + */ +export class UrlbarSearchOneOffs extends SearchOneOffs { + /** + * Constructor. + * + * @param {UrlbarView} view + * The parent UrlbarView. + */ + constructor(view) { + super(view.panel.querySelector(".search-one-offs")); + this.view = view; + this.input = view.input; + lazy.UrlbarPrefs.addObserver(this); + // Override the SearchOneOffs.jsm value for the Address Bar. + this.disableOneOffsHorizontalKeyNavigation = true; + this._webEngines = []; + } + + /** + * Returns the local search mode one-off buttons. + * + * @returns {Array} + * The local one-off buttons. + */ + get localButtons() { + return this.getSelectableButtons(false).filter(b => b.source); + } + + /** + * Invoked when Web provided search engines list changes. + * + * @param {Array} engines Array of Web provided search engines. Each engine + * is defined as { icon, name, tooltip, uri }. + */ + updateWebEngines(engines) { + this._webEngines = engines; + this.invalidateCache(); + if (this.view.isOpen) { + this._rebuild(); + } + } + + /** + * Enables (shows) or disables (hides) the one-offs. + * + * @param {boolean} enable + * True to enable, false to disable. + */ + enable(enable) { + if (enable) { + this.telemetryOrigin = "urlbar"; + this.style.display = ""; + this.textbox = this.view.input.inputField; + if (this.view.isOpen) { + this._rebuild(); + } + this.view.controller.addQueryListener(this); + } else { + this.telemetryOrigin = null; + this.style.display = "none"; + this.textbox = null; + this.view.controller.removeQueryListener(this); + } + } + + /** + * Query listener method. Delegates to the superclass. + */ + onViewOpen() { + this._on_popupshowing(); + } + + /** + * Query listener method. Delegates to the superclass. + */ + onViewClose() { + this._on_popuphidden(); + } + + /** + * @returns {boolean} + * True if the one-offs are connected to a view. + */ + get hasView() { + // Return true if the one-offs are enabled. We set style.display = "none" + // when they're disabled, and we hide the container when there are no + // engines to show. + return this.style.display != "none" && !this.container.hidden; + } + + /** + * @returns {boolean} + * True if the view is open. + */ + get isViewOpen() { + return this.view.isOpen; + } + + /** + * The selected one-off, a xul:button, including the search-settings button. + * + * @param {DOMElement|null} button + * The selected one-off button. Null if no one-off is selected. + */ + set selectedButton(button) { + if (this.selectedButton == button) { + return; + } + + super.selectedButton = button; + + let expectedSearchMode; + if (button && button != this.view.oneOffSearchButtons.settingsButton) { + expectedSearchMode = { + engineName: button.engine?.name, + source: button.source, + entry: "oneoff", + }; + this.input.searchMode = expectedSearchMode; + } else if (this.input.searchMode) { + // Restore the previous state. We do this only if we're in search mode, as + // an optimization in the common case of cycling through normal results. + this.input.restoreSearchModeState(); + } + } + + get selectedButton() { + return super.selectedButton; + } + + /** + * The selected index in the view or -1 if there is no selection. + * + * @returns {number} + */ + get selectedViewIndex() { + return this.view.selectedRowIndex; + } + set selectedViewIndex(val) { + this.view.selectedRowIndex = val; + } + + /** + * Closes the view. + */ + closeView() { + if (this.view) { + this.view.close(); + } + } + + /** + * Called when a one-off is clicked. This is not called for the settings + * button. + * + * @param {event} event + * The event that triggered the pick. + * @param {object} searchMode + * Used by UrlbarInput.setSearchMode to enter search mode. See setSearchMode + * documentation for details. + */ + handleSearchCommand(event, searchMode) { + // The settings button and adding engines are a special case and executed + // immediately. + if ( + this.selectedButton == this.view.oneOffSearchButtons.settingsButton || + this.selectedButton.classList.contains( + "searchbar-engine-one-off-add-engine" + ) + ) { + this.input.controller.engagementEvent.discard(); + this.selectedButton.doCommand(); + this.selectedButton = null; + return; + } + + // We allow autofill in local but not remote search modes. + let startQueryParams = { + allowAutofill: + !searchMode.engineName && + searchMode.source != lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + event, + }; + + let userTypedSearchString = + this.input.value && this.input.getAttribute("pageproxystate") != "valid"; + let engine = Services.search.getEngineByName(searchMode.engineName); + + let { where, params } = this._whereToOpen(event); + + // Some key combinations should execute a search immediately. We handle + // these here, outside the switch statement. + if ( + userTypedSearchString && + engine && + (event.shiftKey || where != "current") + ) { + this.input.handleNavigation({ + event, + oneOffParams: { + openWhere: where, + openParams: params, + engine: this.selectedButton.engine, + }, + }); + this.selectedButton = null; + return; + } + + // Handle opening search mode in either the current tab or in a new tab. + switch (where) { + case "current": { + this.input.searchMode = searchMode; + this.input.startQuery(startQueryParams); + break; + } + case "tab": { + // We set this.selectedButton when switching tabs. If we entered search + // mode preview here, it could be cleared when this.selectedButton calls + // setSearchMode. + searchMode.isPreview = false; + + let newTab = this.input.window.gBrowser.addTrustedTab("about:newtab"); + this.input.setSearchMode(searchMode, newTab.linkedBrowser); + if (userTypedSearchString) { + // Set the search string for the new tab. + newTab.linkedBrowser.userTypedValue = this.input.value; + } + if (!params?.inBackground) { + this.input.window.gBrowser.selectedTab = newTab; + newTab.ownerGlobal.gURLBar.startQuery(startQueryParams); + } + break; + } + default: { + this.input.searchMode = searchMode; + this.input.startQuery(startQueryParams); + this.input.select(); + break; + } + } + + this.selectedButton = null; + } + + /** + * Sets the tooltip for a one-off button with an engine. This should set + * either the `tooltiptext` attribute or the relevant l10n ID. + * + * @param {element} button + * The one-off button. + */ + setTooltipForEngineButton(button) { + let aliases = button.engine.aliases; + if (!aliases.length) { + super.setTooltipForEngineButton(button); + return; + } + this.document.l10n.setAttributes( + button, + "search-one-offs-engine-with-alias", + { + engineName: button.engine.name, + alias: aliases[0], + } + ); + } + + /** + * Overrides the willHide method in the superclass to account for the local + * search mode buttons. + * + * @returns {boolean} + * True if we will hide the one-offs when they are requested. + */ + async willHide() { + // We need to call super.willHide() even when we return false below because + // it has the necessary side effect of creating this._engineInfo. + let superWillHide = await super.willHide(); + if ( + lazy.UrlbarUtils.LOCAL_SEARCH_MODES.some(m => + lazy.UrlbarPrefs.get(m.pref) + ) + ) { + return false; + } + return superWillHide; + } + + /** + * Called when a pref tracked by UrlbarPrefs changes. + * + * @param {string} changedPref + * The name of the pref, relative to `browser.urlbar.` if the pref is in + * that branch. + */ + onPrefChanged(changedPref) { + // Invalidate the engine cache when the local-one-offs-related prefs change + // so that the one-offs rebuild themselves the next time the view opens. + if ( + [...lazy.UrlbarUtils.LOCAL_SEARCH_MODES.map(m => m.pref)].includes( + changedPref + ) + ) { + this.invalidateCache(); + } + } + + /** + * Overrides _getAddEngines to return engines that can be added. + * + * @returns {Array} engines + */ + _getAddEngines() { + return this._webEngines; + } + + /** + * Overrides _rebuildEngineList to add the local one-offs. + * + * @param {Array} engines + * The search engines to add. + * @param {Array} addEngines + * The engines that can be added. + */ + _rebuildEngineList(engines, addEngines) { + super._rebuildEngineList(engines, addEngines); + + for (let { source, pref, restrict } of lazy.UrlbarUtils + .LOCAL_SEARCH_MODES) { + if (!lazy.UrlbarPrefs.get(pref)) { + continue; + } + let name = lazy.UrlbarUtils.getResultSourceName(source); + let button = this.document.createXULElement("button"); + button.id = `urlbar-engine-one-off-item-${name}`; + button.setAttribute("class", "searchbar-engine-one-off-item"); + button.setAttribute("tabindex", "-1"); + this.document.l10n.setAttributes(button, `search-one-offs-${name}`, { + restrict, + }); + button.source = source; + this.buttons.appendChild(button); + } + } + + /** + * Overrides the superclass's click listener to handle clicks on local + * one-offs in addition to engine one-offs. + * + * @param {event} event + * The click event. + */ + _on_click(event) { + // Ignore right clicks. + if (event.button == 2) { + return; + } + + let button = event.originalTarget; + + if (!button.engine && !button.source) { + return; + } + + this.selectedButton = button; + this.handleSearchCommand(event, { + engineName: button.engine?.name, + source: button.source, + entry: "oneoff", + }); + } + + /** + * Overrides the superclass's contextmenu listener to handle the context menu. + * + * @param {event} event + * The contextmenu event. + */ + _on_contextmenu(event) { + // Prevent the context menu from appearing. + event.preventDefault(); + } +} diff --git a/browser/components/urlbar/UrlbarSearchUtils.sys.mjs b/browser/components/urlbar/UrlbarSearchUtils.sys.mjs new file mode 100644 index 0000000000..55606a3130 --- /dev/null +++ b/browser/components/urlbar/UrlbarSearchUtils.sys.mjs @@ -0,0 +1,419 @@ +/* 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/. */ + +/* + * Search service utilities for urlbar. The only reason these functions aren't + * a part of UrlbarUtils is that we want O(1) case-insensitive lookup for search + * aliases, and to do that we need to observe the search service, persistent + * state, and an init method. A separate object is easier. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +/** + * Search service utilities for urlbar. + */ +class SearchUtils { + constructor() { + this._refreshEnginesByAliasPromise = Promise.resolve(); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "separatePrivateDefaultUIEnabled", + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "separatePrivateDefault", + "browser.search.separatePrivateDefault", + false + ); + } + + /** + * Initializes the instance and also Services.search. + */ + async init() { + if (!this._initPromise) { + this._initPromise = this._initInternal(); + } + await this._initPromise; + } + + /** + * Gets the engines whose domains match a given prefix. + * + * @param {string} prefix + * String containing the first part of the matching domain name(s). + * @param {object} [options] + * Options object. + * @param {boolean} [options.matchAllDomainLevels] + * Match at each sub domain, for example "a.b.c.com" will be matched at + * "a.b.c.com", "b.c.com", and "c.com". Partial matches are always returned + * after perfect matches. + * @param {boolean} [options.onlyEnabled] + * Match only engines that have not been disabled on the Search Preferences + * list. + * @returns {Array<nsISearchEngine>} + * An array of all matching engines. An empty array if there are none. + */ + async enginesForDomainPrefix( + prefix, + { matchAllDomainLevels = false, onlyEnabled = false } = {} + ) { + try { + await this.init(); + } catch { + return []; + } + prefix = prefix.toLowerCase(); + + let disabledEngines = onlyEnabled + ? Services.prefs + .getStringPref("browser.search.hiddenOneOffs", "") + .split(",") + .filter(e => !!e) + : []; + + // Array of partially matched engines, added through matchPrefix(). + let partialMatchEngines = []; + function matchPrefix(engine, engineHost) { + let parts = engineHost.split("."); + for (let i = 1; i < parts.length - 1; ++i) { + if (parts.slice(i).join(".").startsWith(prefix)) { + partialMatchEngines.push(engine); + } + } + } + + // Array of perfectly matched engines. We also keep a Set for O(1) lookup. + let perfectMatchEngines = []; + let perfectMatchEngineSet = new Set(); + for (let engine of await Services.search.getVisibleEngines()) { + if (disabledEngines.includes(engine.name)) { + continue; + } + let domain = engine.searchUrlDomain; + if (domain.startsWith(prefix) || domain.startsWith("www." + prefix)) { + perfectMatchEngines.push(engine); + perfectMatchEngineSet.add(engine); + } + + if (matchAllDomainLevels) { + // The prefix may or may not contain part of the public suffix. If + // it contains a dot, we must match with and without the public suffix, + // otherwise it's sufficient to just match without it. + if (prefix.includes(".")) { + matchPrefix(engine, domain); + } + matchPrefix( + engine, + domain.substr(0, domain.length - engine.searchUrlPublicSuffix.length) + ); + } + } + + // Build the final list of matching engines. Partial matches come after + // perfect matches. Partial matches may be included in the perfect matches + // list, so be careful not to include the same engine more than once. + let engines = perfectMatchEngines; + let engineSet = perfectMatchEngineSet; + for (let engine of partialMatchEngines) { + if (!engineSet.has(engine)) { + engineSet.add(engine); + engines.push(engine); + } + } + return engines; + } + + /** + * Gets the engine with a given alias. + * + * @param {string} alias + * A search engine alias. The alias string comparison is case insensitive. + * @param {string} [searchString] + * Optional. If provided, we also enforce that there must be a space after + * the alias in the search string. + * @returns {nsISearchEngine} + * The matching engine or null if there isn't one. + */ + async engineForAlias(alias, searchString = null) { + try { + await Promise.all([this.init(), this._refreshEnginesByAliasPromise]); + } catch { + return null; + } + + let engine = this._enginesByAlias.get(alias.toLocaleLowerCase()); + if (engine && searchString) { + let query = lazy.UrlbarUtils.substringAfter(searchString, alias); + // Match an alias only when it has a space after it. If there's no trailing + // space, then continue to treat it as part of the search string. + if (!lazy.UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return null; + } + } + return engine || null; + } + + /** + * The list of engines with token ("@") aliases. + * + * @returns {Array} + * Array of objects { engine, tokenAliases } for token alias engines or + * null if SearchService has not initialized. + */ + async tokenAliasEngines() { + try { + await this.init(); + } catch { + return []; + } + + let tokenAliasEngines = []; + for (let engine of await Services.search.getVisibleEngines()) { + let tokenAliases = this._aliasesForEngine(engine).filter(a => + a.startsWith("@") + ); + if (tokenAliases.length) { + tokenAliasEngines.push({ engine, tokenAliases }); + } + } + return tokenAliasEngines; + } + + /** + * @param {nsISearchEngine} engine + * The engine to get the root domain of + * @returns {string} + * The root domain of a search engine. e.g. If `engine` has the domain + * www.subdomain.rootdomain.com, `rootdomain` is returned. Returns the + * engine's domain if the engine's URL does not have a valid TLD. + */ + getRootDomainFromEngine(engine) { + let domain = engine.searchUrlDomain; + let suffix = engine.searchUrlPublicSuffix; + if (!suffix) { + if (domain.endsWith(".test")) { + suffix = "test"; + } else { + return domain; + } + } + domain = domain.substr( + 0, + // -1 to remove the trailing dot. + domain.length - suffix.length - 1 + ); + let domainParts = domain.split("."); + return domainParts.pop(); + } + + /** + * @param {boolean} [isPrivate] + * True if in a private context. + * @returns {nsISearchEngine} + * The default engine or null if SearchService has not initialized. + */ + getDefaultEngine(isPrivate = false) { + if (!Services.search.hasSuccessfullyInitialized) { + return null; + } + + return this.separatePrivateDefaultUIEnabled && + this.separatePrivateDefault && + isPrivate + ? Services.search.defaultPrivateEngine + : Services.search.defaultEngine; + } + + /** + * To make analysis easier, we sanitize some engine names when + * recording telemetry about search mode. This function returns the sanitized + * key name to record in telemetry. + * + * @param {object} searchMode + * A search mode object. See UrlbarInput.setSearchMode. + * @returns {string} + * A sanitized scalar key, used to access Telemetry data. + */ + getSearchModeScalarKey(searchMode) { + let scalarKey; + if (searchMode.engineName) { + let engine = Services.search.getEngineByName(searchMode.engineName); + let resultDomain = engine.searchUrlDomain; + // For built-in engines, sanitize the data in a few special cases to make + // analysis easier. + if (!engine.isAppProvided) { + scalarKey = "other"; + } else if (resultDomain.includes("amazon.")) { + // Group all the localized Amazon sites together. + scalarKey = "Amazon"; + } else if (resultDomain.endsWith("wikipedia.org")) { + // Group all the localized Wikipedia sites together. + scalarKey = "Wikipedia"; + } else { + scalarKey = searchMode.engineName; + } + } else if (searchMode.source) { + scalarKey = + lazy.UrlbarUtils.getResultSourceName(searchMode.source) || "other"; + } + + return scalarKey; + } + + async _initInternal() { + await Services.search.init(); + await this._refreshEnginesByAlias(); + Services.obs.addObserver(this, SEARCH_ENGINE_TOPIC, true); + } + + async _refreshEnginesByAlias() { + // See the comment at the top of this file. The only reason we need this + // class is for O(1) case-insensitive lookup for search aliases, which is + // facilitated by _enginesByAlias. + this._enginesByAlias = new Map(); + for (let engine of await Services.search.getVisibleEngines()) { + if (!engine.hidden) { + for (let alias of this._aliasesForEngine(engine)) { + this._enginesByAlias.set(alias, engine); + } + } + } + } + + /** + * Checks if the given uri is constructed by the default search engine. + * When passing URI's to check against, it's best to use the "original" URI + * that was requested, as the server may have redirected the request. + * + * @param {nsIURI | string} uri + * The uri to check. + * @returns {string} + * The search terms used. + * Will return an empty string if it's not a default SERP + * or if the default engine hasn't been initialized. + */ + getSearchTermIfDefaultSerpUri(uri) { + if (!Services.search.hasSuccessfullyInitialized || !uri) { + return ""; + } + + // Creating a URI can throw. + try { + if (typeof uri == "string") { + uri = Services.io.newURI(uri); + } + } catch (e) { + return ""; + } + + return Services.search.defaultEngine.searchTermFromResult(uri); + } + + /** + * Compares the query parameters of two SERPs to see if one is equivalent to + * the other. URL `x` is equivalent to URL `y` if + * (a) `y` contains at least all the query parameters contained in `x`, and + * (b) The values of the query parameters contained in both `x` and `y `are + * the same. + * + * This function does not compare the SERPs' origins or pathnames. + * `historySerp` can have a different origin and/or pathname than + * `generatedSerp` and still be considered equivalent. + * + * @param {string} historySerp + * The SERP from history whose params should be contained in + * `generatedSerp`. + * @param {string} generatedSerp + * The search URL we would generate for a search result with the same search + * string used in `historySerp`. + * @param {Array} [ignoreParams] + * A list of params to ignore in the matching, i.e. params that can be + * contained in `historySerp` but not be in `generatedSerp`. + * @returns {boolean} True if `historySerp` can be deduped by `generatedSerp`. + */ + serpsAreEquivalent(historySerp, generatedSerp, ignoreParams = []) { + let historyParams = new URL(historySerp).searchParams; + let generatedParams = new URL(generatedSerp).searchParams; + if ( + !Array.from(historyParams.entries()).every( + ([key, value]) => + ignoreParams.includes(key) || value === generatedParams.get(key) + ) + ) { + return false; + } + + return true; + } + + /** + * Gets the aliases of an engine. For the user's convenience, we recognize + * token versions of all non-token aliases. For example, if the user has an + * alias of "foo", then we recognize both "foo" and "@foo" as aliases for + * foo's engine. The returned list is therefore a superset of + * `engine.aliases`. Additionally, the returned aliases will be lower-cased + * to make lookups and comparisons easier. + * + * @param {nsISearchEngine} engine + * The aliases of this search engine will be returned. + * @returns {Array} + * An array of lower-cased string aliases as described above. + */ + _aliasesForEngine(engine) { + return engine.aliases.reduce((aliases, aliasWithCase) => { + // We store lower-cased aliases to make lookups and comparisons easier. + let alias = aliasWithCase.toLocaleLowerCase(); + aliases.push(alias); + if (!alias.startsWith("@")) { + aliases.push("@" + alias); + } + return aliases; + }, []); + } + + /** + * @param {string} engineName + * Name of the search engine. + * @returns {nsISearchEngine} + * The engine based on engineName or null if SearchService has not + * initialized. + */ + getEngineByName(engineName) { + if (!Services.search.hasSuccessfullyInitialized) { + return null; + } + + return Services.search.getEngineByName(engineName); + } + + observe(subject, topic, data) { + switch (data) { + case "engine-added": + case "engine-changed": + case "engine-removed": + case "engine-default": + this._refreshEnginesByAliasPromise = this._refreshEnginesByAlias(); + break; + } + } +} + +export var UrlbarSearchUtils = new SearchUtils(); diff --git a/browser/components/urlbar/UrlbarTokenizer.sys.mjs b/browser/components/urlbar/UrlbarTokenizer.sys.mjs new file mode 100644 index 0000000000..bbe12aa2fc --- /dev/null +++ b/browser/components/urlbar/UrlbarTokenizer.sys.mjs @@ -0,0 +1,416 @@ +/* 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/. */ + +/** + * This module exports a tokenizer to be used by the urlbar model. + * Emitted tokens are objects in the shape { type, value }, where type is one + * of UrlbarTokenizer.TYPE. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logger", () => + lazy.UrlbarUtils.getLogger({ prefix: "Tokenizer" }) +); + +export var UrlbarTokenizer = { + // Regex matching on whitespaces. + REGEXP_SPACES: /\s+/, + REGEXP_SPACES_START: /^\s+/, + + // Regex used to guess url-like strings. + // These are not expected to be 100% correct, we accept some user mistypes + // and we're unlikely to be able to cover 100% of the cases. + REGEXP_LIKE_PROTOCOL: /^[A-Z+.-]+:\/*(?!\/)/i, + REGEXP_USERINFO_INVALID_CHARS: /[^\w.~%!$&'()*+,;=:-]/, + REGEXP_HOSTPORT_INVALID_CHARS: /[^\[\]A-Z0-9.:-]/i, + REGEXP_SINGLE_WORD_HOST: /^[^.:]+$/i, + REGEXP_HOSTPORT_IP_LIKE: /^(?=(.*[.:].*){2})[a-f0-9\.\[\]:]+$/i, + // This accepts partial IPv4. + REGEXP_HOSTPORT_INVALID_IP: + /\.{2,}|\d{5,}|\d{4,}(?![:\]])|^\.|^(\d+\.){4,}\d+$|^\d{4,}$/, + // This only accepts complete IPv4. + REGEXP_HOSTPORT_IPV4: /^(\d{1,3}\.){3,}\d{1,3}(:\d+)?$/, + // This accepts partial IPv6. + REGEXP_HOSTPORT_IPV6: /^\[([0-9a-f]{0,4}:){0,7}[0-9a-f]{0,4}\]?$/i, + REGEXP_COMMON_EMAIL: /^[\w!#$%&'*+/=?^`{|}~.-]+@[\[\]A-Z0-9.-]+$/i, + REGEXP_HAS_PORT: /:\d+$/, + // Regex matching a percent encoded char at the beginning of a string. + REGEXP_PERCENT_ENCODED_START: /^(%[0-9a-f]{2}){2,}/i, + // Regex matching scheme and colon, plus, if present, two slashes. + REGEXP_PREFIX: /^[a-z-]+:(?:\/){0,2}/i, + + TYPE: { + TEXT: 1, + POSSIBLE_ORIGIN: 2, // It may be an ip, a domain, but even just a single word used as host. + POSSIBLE_URL: 3, // Consumers should still check this with a fixup. + RESTRICT_HISTORY: 4, + RESTRICT_BOOKMARK: 5, + RESTRICT_TAG: 6, + RESTRICT_OPENPAGE: 7, + RESTRICT_SEARCH: 8, + RESTRICT_TITLE: 9, + RESTRICT_URL: 10, + RESTRICT_ACTION: 11, + }, + + // The special characters below can be typed into the urlbar to restrict + // the search to a certain category, like history, bookmarks or open pages; or + // to force a match on just the title or url. + // These restriction characters can be typed alone, or at word boundaries, + // provided their meaning cannot be confused, for example # could be present + // in a valid url, and thus it should not be interpreted as a restriction. + RESTRICT: { + HISTORY: "^", + BOOKMARK: "*", + TAG: "+", + OPENPAGE: "%", + SEARCH: "?", + TITLE: "#", + URL: "$", + ACTION: ">", + }, + + // The keys of characters in RESTRICT that will enter search mode. + get SEARCH_MODE_RESTRICT() { + return new Set([ + this.RESTRICT.HISTORY, + this.RESTRICT.BOOKMARK, + this.RESTRICT.OPENPAGE, + this.RESTRICT.SEARCH, + this.RESTRICT.ACTION, + ]); + }, + + /** + * Returns whether the passed in token looks like a URL. + * This is based on guessing and heuristics, that means if this function + * returns false, it's surely not a URL, if it returns true, the result must + * still be verified through URIFixup. + * + * @param {string} token + * The string token to verify + * @param {boolean} [requirePath] The url must have a path + * @returns {boolean} whether the token looks like a URL. + */ + looksLikeUrl(token, { requirePath = false } = {}) { + if (token.length < 2) { + return false; + } + // Ignore spaces and require path for the data: protocol. + if (token.startsWith("data:")) { + return token.length > 5; + } + if (this.REGEXP_SPACES.test(token)) { + return false; + } + // If it starts with something that looks like a protocol, it's likely a url. + if (this.REGEXP_LIKE_PROTOCOL.test(token)) { + return true; + } + // Guess path and prePath. At this point we should be analyzing strings not + // having a protocol. + let slashIndex = token.indexOf("/"); + let prePath = slashIndex != -1 ? token.slice(0, slashIndex) : token; + if (!this.looksLikeOrigin(prePath, { ignoreKnownDomains: true })) { + return false; + } + + let path = slashIndex != -1 ? token.slice(slashIndex) : ""; + lazy.logger.debug("path", path); + if (requirePath && !path) { + return false; + } + // If there are both path and userinfo, it's likely a url. + let atIndex = prePath.indexOf("@"); + let userinfo = atIndex != -1 ? prePath.slice(0, atIndex) : ""; + if (path.length && userinfo.length) { + return true; + } + + // If the first character after the slash in the path is a letter, then the + // token may be an "abc/def" url. + if (/^\/[a-z]/i.test(path)) { + return true; + } + // If the path contains special chars, it is likely a url. + if (["%", "?", "#"].some(c => path.includes(c))) { + return true; + } + + // The above looksLikeOrigin call told us the prePath looks like an origin, + // now we go into details checking some common origins. + let hostPort = atIndex != -1 ? prePath.slice(atIndex + 1) : prePath; + if (this.REGEXP_HOSTPORT_IPV4.test(hostPort)) { + return true; + } + // ipv6 is very complex to support, just check for a few chars. + if ( + this.REGEXP_HOSTPORT_IPV6.test(hostPort) && + ["[", "]", ":"].some(c => hostPort.includes(c)) + ) { + return true; + } + if (Services.uriFixup.isDomainKnown(hostPort)) { + return true; + } + return false; + }, + + /** + * Returns whether the passed in token looks like an origin. + * This is based on guessing and heuristics, that means if this function + * returns false, it's surely not an origin, if it returns true, the result + * must still be verified through URIFixup. + * + * @param {string} token + * The string token to verify + * @param {object} options Options object + * @param {boolean} [options.ignoreKnownDomains] If true, the origin doesn't have to be + * in the known domain list + * @param {boolean} [options.noIp] If true, the origin cannot be an IP address + * @param {boolean} [options.noPort] If true, the origin cannot have a port number + * @returns {boolean} whether the token looks like an origin. + */ + looksLikeOrigin( + token, + { ignoreKnownDomains = false, noIp = false, noPort = false } = {} + ) { + if (!token.length) { + return false; + } + let atIndex = token.indexOf("@"); + if (atIndex != -1 && this.REGEXP_COMMON_EMAIL.test(token)) { + // We prefer handling it as an email rather than an origin with userinfo. + return false; + } + let userinfo = atIndex != -1 ? token.slice(0, atIndex) : ""; + let hostPort = atIndex != -1 ? token.slice(atIndex + 1) : token; + let hasPort = this.REGEXP_HAS_PORT.test(hostPort); + lazy.logger.debug("userinfo", userinfo); + lazy.logger.debug("hostPort", hostPort); + if (noPort && hasPort) { + return false; + } + if ( + this.REGEXP_HOSTPORT_IPV4.test(hostPort) || + this.REGEXP_HOSTPORT_IPV6.test(hostPort) + ) { + return !noIp; + } + + // Check for invalid chars. + if ( + this.REGEXP_LIKE_PROTOCOL.test(hostPort) || + this.REGEXP_USERINFO_INVALID_CHARS.test(userinfo) || + this.REGEXP_HOSTPORT_INVALID_CHARS.test(hostPort) || + (!this.REGEXP_SINGLE_WORD_HOST.test(hostPort) && + this.REGEXP_HOSTPORT_IP_LIKE.test(hostPort) && + this.REGEXP_HOSTPORT_INVALID_IP.test(hostPort)) + ) { + return false; + } + + // If it looks like a single word host, check the known domains. + if ( + !ignoreKnownDomains && + !userinfo && + !hasPort && + this.REGEXP_SINGLE_WORD_HOST.test(hostPort) + ) { + return Services.uriFixup.isDomainKnown(hostPort); + } + + return true; + }, + + /** + * Tokenizes the searchString from a UrlbarQueryContext. + * + * @param {UrlbarQueryContext} queryContext + * The query context object to tokenize + * @returns {UrlbarQueryContext} the same query context object with a new + * tokens property. + */ + tokenize(queryContext) { + lazy.logger.debug( + "Tokenizing search string", + JSON.stringify(queryContext.searchString) + ); + if (!queryContext.trimmedSearchString) { + queryContext.tokens = []; + return queryContext; + } + let unfiltered = splitString(queryContext.searchString); + let tokens = filterTokens(unfiltered); + queryContext.tokens = tokens; + return queryContext; + }, + + /** + * Given a token, tells if it's a restriction token. + * + * @param {object} token + * The token to check. + * @returns {boolean} Whether the token is a restriction character. + */ + isRestrictionToken(token) { + return ( + token && + token.type >= this.TYPE.RESTRICT_HISTORY && + token.type <= this.TYPE.RESTRICT_URL + ); + }, +}; + +const CHAR_TO_TYPE_MAP = new Map( + Object.entries(UrlbarTokenizer.RESTRICT).map(([type, char]) => [ + char, + UrlbarTokenizer.TYPE[`RESTRICT_${type}`], + ]) +); + +/** + * Given a search string, splits it into string tokens. + * + * @param {string} searchString + * The search string to split + * @returns {Array} An array of string tokens. + */ +function splitString(searchString) { + // The first step is splitting on unicode whitespaces. We ignore whitespaces + // if the search string starts with "data:", to better support Web developers + // and compatiblity with other browsers. + let trimmed = searchString.trim(); + let tokens = trimmed.startsWith("data:") + ? [trimmed] + : trimmed.split(UrlbarTokenizer.REGEXP_SPACES); + + if (!tokens.length) { + return tokens; + } + + // If there is no separate restriction token, it's possible we have to split + // a token, if it's the first one and it includes a leading restriction char + // or it's the last one and it includes a trailing restriction char. + // This allows to not require the user to add artificial whitespaces to + // enforce restrictions, for example typing questions would restrict to + // search results. + const hasRestrictionToken = tokens.some(t => CHAR_TO_TYPE_MAP.has(t)); + if (hasRestrictionToken) { + return tokens; + } + + // Check for an unambiguous restriction char at the beginning of the first + // token, or at the end of the last token. We only count trailing restriction + // chars if they are the search restriction char, which is "?". This is to + // allow for a typed question to yield only search results. + const firstToken = tokens[0]; + if ( + CHAR_TO_TYPE_MAP.has(firstToken[0]) && + !UrlbarTokenizer.REGEXP_PERCENT_ENCODED_START.test(firstToken) + ) { + tokens[0] = firstToken.substring(1); + tokens.splice(0, 0, firstToken[0]); + return tokens; + } + + const lastIndex = tokens.length - 1; + const lastToken = tokens[lastIndex]; + if ( + lastToken[lastToken.length - 1] == UrlbarTokenizer.RESTRICT.SEARCH && + !UrlbarTokenizer.looksLikeUrl(lastToken, { requirePath: true }) + ) { + tokens[lastIndex] = lastToken.substring(0, lastToken.length - 1); + tokens.push(lastToken[lastToken.length - 1]); + } + + return tokens; +} + +/** + * Given an array of unfiltered tokens, this function filters them and converts + * to token objects with a type. + * + * @param {Array} tokens + * An array of strings, representing search tokens. + * @returns {Array} An array of token objects. + * Note: restriction characters are only considered if they appear at the start + * or at the end of the tokens list. In case of restriction characters + * conflict, the most external ones win. Leading ones win over trailing + * ones. Discarded restriction characters are considered text. + */ +function filterTokens(tokens) { + let filtered = []; + let restrictions = []; + for (let i = 0; i < tokens.length; ++i) { + let token = tokens[i]; + let tokenObj = { + value: token, + lowerCaseValue: token.toLocaleLowerCase(), + type: UrlbarTokenizer.TYPE.TEXT, + }; + let restrictionType = CHAR_TO_TYPE_MAP.get(token); + if (restrictionType) { + restrictions.push({ index: i, type: restrictionType }); + } else if (UrlbarTokenizer.looksLikeOrigin(token)) { + tokenObj.type = UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN; + } else if (UrlbarTokenizer.looksLikeUrl(token, { requirePath: true })) { + tokenObj.type = UrlbarTokenizer.TYPE.POSSIBLE_URL; + } + filtered.push(tokenObj); + } + + // Handle restriction characters. + if (restrictions.length) { + // We can apply two kind of restrictions: type (bookmark, search, ...) and + // matching (url, title). These kind of restrictions can be combined, but we + // can only have one restriction per kind. + let matchingRestrictionFound = false; + let typeRestrictionFound = false; + function assignRestriction(r) { + if (r && !(matchingRestrictionFound && typeRestrictionFound)) { + if ( + [ + UrlbarTokenizer.TYPE.RESTRICT_TITLE, + UrlbarTokenizer.TYPE.RESTRICT_URL, + ].includes(r.type) + ) { + if (!matchingRestrictionFound) { + matchingRestrictionFound = true; + filtered[r.index].type = r.type; + return true; + } + } else if (!typeRestrictionFound) { + typeRestrictionFound = true; + filtered[r.index].type = r.type; + return true; + } + } + return false; + } + + // Look at the first token. + let found = assignRestriction(restrictions.find(r => r.index == 0)); + if (found) { + // If the first token was assigned, look at the next one. + assignRestriction(restrictions.find(r => r.index == 1)); + } + // Then look at the last token. + let lastIndex = tokens.length - 1; + found = assignRestriction(restrictions.find(r => r.index == lastIndex)); + if (found) { + // If the last token was assigned, look at the previous one. + assignRestriction(restrictions.find(r => r.index == lastIndex - 1)); + } + } + + lazy.logger.info("Filtered Tokens", filtered); + return filtered; +} diff --git a/browser/components/urlbar/UrlbarUtils.sys.mjs b/browser/components/urlbar/UrlbarUtils.sys.mjs new file mode 100644 index 0000000000..433aaf7472 --- /dev/null +++ b/browser/components/urlbar/UrlbarUtils.sys.mjs @@ -0,0 +1,2806 @@ +/* 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/. */ + +/** + * This module exports the UrlbarUtils singleton, which contains constants and + * helper functions that are useful to all components of the urlbar. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + KeywordUtils: "resource://gre/modules/KeywordUtils.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarProviderSearchTips: + "resource:///modules/UrlbarProviderSearchTips.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", +}); + +export var UrlbarUtils = { + // Results are categorized into groups to help the muxer compose them. See + // UrlbarUtils.getResultGroup. Since result groups are stored in result + // groups and result groups are stored in prefs, additions and changes to + // result groups may require adding UI migrations to BrowserGlue. Be careful + // about making trivial changes to existing groups, like renaming them, + // because we don't want to make downgrades unnecessarily hard. + RESULT_GROUP: { + ABOUT_PAGES: "aboutPages", + GENERAL: "general", + GENERAL_PARENT: "generalParent", + FORM_HISTORY: "formHistory", + HEURISTIC_AUTOFILL: "heuristicAutofill", + HEURISTIC_ENGINE_ALIAS: "heuristicEngineAlias", + HEURISTIC_EXTENSION: "heuristicExtension", + HEURISTIC_FALLBACK: "heuristicFallback", + HEURISTIC_BOOKMARK_KEYWORD: "heuristicBookmarkKeyword", + HEURISTIC_HISTORY_URL: "heuristicHistoryUrl", + HEURISTIC_OMNIBOX: "heuristicOmnibox", + HEURISTIC_PRELOADED: "heuristicPreloaded", + HEURISTIC_SEARCH_TIP: "heuristicSearchTip", + HEURISTIC_TEST: "heuristicTest", + HEURISTIC_TOKEN_ALIAS_ENGINE: "heuristicTokenAliasEngine", + INPUT_HISTORY: "inputHistory", + OMNIBOX: "extension", + PRELOADED: "preloaded", + REMOTE_SUGGESTION: "remoteSuggestion", + REMOTE_TAB: "remoteTab", + SUGGESTED_INDEX: "suggestedIndex", + TAIL_SUGGESTION: "tailSuggestion", + }, + + // Defines provider types. + PROVIDER_TYPE: { + // Should be executed immediately, because it returns heuristic results + // that must be handed to the user asap. + HEURISTIC: 1, + // Can be delayed, contains results coming from the session or the profile. + PROFILE: 2, + // Can be delayed, contains results coming from the network. + NETWORK: 3, + // Can be delayed, contains results coming from unknown sources. + EXTENSION: 4, + }, + + // Defines UrlbarResult types. + RESULT_TYPE: { + // An open tab. + TAB_SWITCH: 1, + // A search suggestion or engine. + SEARCH: 2, + // A common url/title tuple, may be a bookmark with tags. + URL: 3, + // A bookmark keyword. + KEYWORD: 4, + // A WebExtension Omnibox result. + OMNIBOX: 5, + // A tab from another synced device. + REMOTE_TAB: 6, + // An actionable message to help the user with their query. + TIP: 7, + // A type of result created at runtime, for example by an extension. + DYNAMIC: 8, + + // When you add a new type, also add its schema to + // UrlbarUtils.RESULT_PAYLOAD_SCHEMA below. Also consider checking if + // consumers of "urlbar-user-start-navigation" need updating. + }, + + // This defines the source of results returned by a provider. Each provider + // can return results from more than one source. This is used by the + // ProvidersManager to decide which providers must be queried and which + // results can be returned. + // If you add new source types, consider checking if consumers of + // "urlbar-user-start-navigation" need update as well. + RESULT_SOURCE: { + BOOKMARKS: 1, + HISTORY: 2, + SEARCH: 3, + TABS: 4, + OTHER_LOCAL: 5, + OTHER_NETWORK: 6, + ACTIONS: 7, + ADDON: 8, + }, + + // This defines icon locations that are commonly used in the UI. + ICON: { + // DEFAULT is defined lazily so it doesn't eagerly initialize PlacesUtils. + EXTENSION: "chrome://mozapps/skin/extensions/extension.svg", + HISTORY: "chrome://browser/skin/history.svg", + SEARCH_GLASS: "chrome://global/skin/icons/search-glass.svg", + TRENDING: "chrome://global/skin/icons/trending.svg", + TIP: "chrome://global/skin/icons/lightbulb.svg", + }, + + // The number of results by which Page Up/Down move the selection. + PAGE_UP_DOWN_DELTA: 5, + + // IME composition states. + COMPOSITION: { + NONE: 1, + COMPOSING: 2, + COMMIT: 3, + CANCELED: 4, + }, + + // Limit the length of titles and URLs we display so layout doesn't spend too + // much time building text runs. + MAX_TEXT_LENGTH: 255, + + // Whether a result should be highlighted up to the point the user has typed + // or after that point. + HIGHLIGHT: { + NONE: 0, + TYPED: 1, + SUGGESTED: 2, + }, + + // UrlbarProviderPlaces's autocomplete results store their titles and tags + // together in their comments. This separator is used to separate them. + // After bug 1717511, we should stop using this old hack and store titles and + // tags separately. It's important that this be a character that no title + // would ever have. We use \x1F, the non-printable unit separator. + TITLE_TAGS_SEPARATOR: "\x1F", + + // Regex matching single word hosts with an optional port; no spaces, auth or + // path-like chars are admitted. + REGEXP_SINGLE_WORD: /^[^\s@:/?#]+(:\d+)?$/, + + // Valid entry points for search mode. If adding a value here, please update + // telemetry documentation and Scalars.yaml. + SEARCH_MODE_ENTRY: new Set([ + "bookmarkmenu", + "handoff", + "keywordoffer", + "oneoff", + "historymenu", + "other", + "shortcut", + "tabmenu", + "tabtosearch", + "tabtosearch_onboard", + "topsites_newtab", + "topsites_urlbar", + "touchbar", + "typed", + ]), + + // The favicon service stores icons for URLs with the following protocols. + PROTOCOLS_WITH_ICONS: [ + "chrome:", + "moz-extension:", + "about:", + "http:", + "https:", + "ftp:", + ], + + // Valid URI schemes that are considered safe but don't contain + // an authority component (e.g host:port). There are many URI schemes + // that do not contain an authority, but these in particular have + // some likelihood of being entered or bookmarked by a user. + // `file:` is an exceptional case because an authority is optional + PROTOCOLS_WITHOUT_AUTHORITY: [ + "about:", + "data:", + "file:", + "javascript:", + "view-source:", + ], + + // Search mode objects corresponding to the local shortcuts in the view, in + // order they appear. Pref names are relative to the `browser.urlbar` branch. + get LOCAL_SEARCH_MODES() { + return [ + { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + restrict: lazy.UrlbarTokenizer.RESTRICT.BOOKMARK, + icon: "chrome://browser/skin/bookmark.svg", + pref: "shortcuts.bookmarks", + telemetryLabel: "bookmarks", + }, + { + source: UrlbarUtils.RESULT_SOURCE.TABS, + restrict: lazy.UrlbarTokenizer.RESTRICT.OPENPAGE, + icon: "chrome://browser/skin/tab.svg", + pref: "shortcuts.tabs", + telemetryLabel: "tabs", + }, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + restrict: lazy.UrlbarTokenizer.RESTRICT.HISTORY, + icon: "chrome://browser/skin/history.svg", + pref: "shortcuts.history", + telemetryLabel: "history", + }, + { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + restrict: lazy.UrlbarTokenizer.RESTRICT.ACTION, + icon: "chrome://browser/skin/quickactions.svg", + pref: "shortcuts.quickactions", + telemetryLabel: "actions", + }, + ]; + }, + + /** + * Returns the payload schema for the given type of result. + * + * @param {number} type One of the UrlbarUtils.RESULT_TYPE values. + * @returns {object} The schema for the given type. + */ + getPayloadSchema(type) { + return UrlbarUtils.RESULT_PAYLOAD_SCHEMA[type]; + }, + + /** + * Adds a url to history as long as it isn't in a private browsing window, + * and it is valid. + * + * @param {string} url The url to add to history. + * @param {nsIDomWindow} window The window from where the url is being added. + */ + addToUrlbarHistory(url, window) { + if ( + !lazy.PrivateBrowsingUtils.isWindowPrivate(window) && + url && + !url.includes(" ") && + // eslint-disable-next-line no-control-regex + !/[\x00-\x1F]/.test(url) + ) { + lazy.PlacesUIUtils.markPageAsTyped(url); + } + }, + + /** + * Given a string, will generate a more appropriate urlbar value if a Places + * keyword or a search alias is found at the beginning of it. + * + * @param {string} url + * A string that may begin with a keyword or an alias. + * + * @returns {Promise<{ url, postData, mayInheritPrincipal }>} + * If it's not possible to discern a keyword or an alias, url will be + * the input string. + */ + async getShortcutOrURIAndPostData(url) { + let mayInheritPrincipal = false; + let postData = null; + // Split on the first whitespace. + let [keyword, param = ""] = url.trim().split(/\s(.+)/, 2); + + if (!keyword) { + return { url, postData, mayInheritPrincipal }; + } + + let engine = await Services.search.getEngineByAlias(keyword); + if (engine) { + let submission = engine.getSubmission(param, null, "keyword"); + return { + url: submission.uri.spec, + postData: submission.postData, + mayInheritPrincipal, + }; + } + + // A corrupt Places database could make this throw, breaking navigation + // from the location bar. + let entry = null; + try { + entry = await lazy.PlacesUtils.keywords.fetch(keyword); + } catch (ex) { + console.error(`Unable to fetch Places keyword "${keyword}": ${ex}`); + } + if (!entry || !entry.url) { + // This is not a Places keyword. + return { url, postData, mayInheritPrincipal }; + } + + try { + [url, postData] = await lazy.KeywordUtils.parseUrlAndPostData( + entry.url.href, + entry.postData, + param + ); + if (postData) { + postData = this.getPostDataStream(postData); + } + + // Since this URL came from a bookmark, it's safe to let it inherit the + // current document's principal. + mayInheritPrincipal = true; + } catch (ex) { + // It was not possible to bind the param, just use the original url value. + } + + return { url, postData, mayInheritPrincipal }; + }, + + /** + * Returns an input stream wrapper for the given post data. + * + * @param {string} postDataString The string to wrap. + * @param {string} [type] The encoding type. + * @returns {nsIInputStream} An input stream of the wrapped post data. + */ + getPostDataStream( + postDataString, + type = "application/x-www-form-urlencoded" + ) { + let dataStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + dataStream.data = postDataString; + + let mimeStream = Cc[ + "@mozilla.org/network/mime-input-stream;1" + ].createInstance(Ci.nsIMIMEInputStream); + mimeStream.addHeader("Content-Type", type); + mimeStream.setData(dataStream); + return mimeStream.QueryInterface(Ci.nsIInputStream); + }, + + _compareIgnoringDiacritics: null, + + /** + * Returns a list of all the token substring matches in a string. Matching is + * case insensitive. Each match in the returned list is a tuple: [matchIndex, + * matchLength]. matchIndex is the index in the string of the match, and + * matchLength is the length of the match. + * + * @param {Array} tokens The tokens to search for. + * @param {string} str The string to match against. + * @param {boolean} highlightType + * One of the HIGHLIGHT values: + * TYPED: match ranges matching the tokens; or + * SUGGESTED: match ranges for words not matching the tokens and the + * endings of words that start with a token. + * @returns {Array} An array: [ + * [matchIndex_0, matchLength_0], + * [matchIndex_1, matchLength_1], + * ... + * [matchIndex_n, matchLength_n] + * ]. + * The array is sorted by match indexes ascending. + */ + getTokenMatches(tokens, str, highlightType) { + // Only search a portion of the string, because not more than a certain + // amount of characters are visible in the UI, matching over what is visible + // would be expensive and pointless. + str = str.substring(0, UrlbarUtils.MAX_TEXT_LENGTH).toLocaleLowerCase(); + // To generate non-overlapping ranges, we start from a 0-filled array with + // the same length of the string, and use it as a collision marker, setting + // 1 where the text should be highlighted. + let hits = new Array(str.length).fill( + highlightType == this.HIGHLIGHT.SUGGESTED ? 1 : 0 + ); + let compareIgnoringDiacritics; + for (let i = 0, totalTokensLength = 0; i < tokens.length; i++) { + const { lowerCaseValue: needle } = tokens[i]; + + // Ideally we should never hit the empty token case, but just in case + // the `needle` check protects us from an infinite loop. + if (!needle) { + continue; + } + let index = 0; + let found = false; + // First try a diacritic-sensitive search. + for (;;) { + index = str.indexOf(needle, index); + if (index < 0) { + break; + } + + if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) { + // We de-emphasize the match only if it's preceded by a space, thus + // it's a perfect match or the beginning of a longer word. + let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; + if (index != previousSpaceIndex) { + index += needle.length; + // We found the token but we won't de-emphasize it, because it's not + // after a word boundary. + found = true; + continue; + } + } + + hits.fill( + highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, + index, + index + needle.length + ); + index += needle.length; + found = true; + } + // If that fails to match anything, try a (computationally intensive) + // diacritic-insensitive search. + if (!found) { + if (!compareIgnoringDiacritics) { + if (!this._compareIgnoringDiacritics) { + // Diacritic insensitivity in the search engine follows a set of + // general rules that are not locale-dependent, so use a generic + // English collator for highlighting matching words instead of a + // collator for the user's particular locale. + this._compareIgnoringDiacritics = new Intl.Collator("en", { + sensitivity: "base", + }).compare; + } + compareIgnoringDiacritics = this._compareIgnoringDiacritics; + } + index = 0; + while (index < str.length) { + let hay = str.substr(index, needle.length); + if (compareIgnoringDiacritics(needle, hay) === 0) { + if (highlightType == UrlbarUtils.HIGHLIGHT.SUGGESTED) { + let previousSpaceIndex = str.lastIndexOf(" ", index) + 1; + if (index != previousSpaceIndex) { + index += needle.length; + continue; + } + } + hits.fill( + highlightType == this.HIGHLIGHT.SUGGESTED ? 0 : 1, + index, + index + needle.length + ); + index += needle.length; + } else { + index++; + } + } + } + + totalTokensLength += needle.length; + if (totalTokensLength > UrlbarUtils.MAX_TEXT_LENGTH) { + // Limit the number of tokens to reduce calculate time. + break; + } + } + // Starting from the collision array, generate [start, len] tuples + // representing the ranges to be highlighted. + let ranges = []; + for (let index = hits.indexOf(1); index >= 0 && index < hits.length; ) { + let len = 0; + // eslint-disable-next-line no-empty + for (let j = index; j < hits.length && hits[j]; ++j, ++len) {} + ranges.push([index, len]); + // Move to the next 1. + index = hits.indexOf(1, index + len); + } + return ranges; + }, + + /** + * Returns the group for a result. + * + * @param {UrlbarResult} result + * The result. + * @returns {UrlbarUtils.RESULT_GROUP} + * The reuslt's group. + */ + getResultGroup(result) { + if (result.group) { + return result.group; + } + + if (result.hasSuggestedIndex && !result.isSuggestedIndexRelativeToGroup) { + return UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX; + } + if (result.heuristic) { + switch (result.providerName) { + case "AliasEngines": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS; + case "Autofill": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL; + case "BookmarkKeywords": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD; + case "HeuristicFallback": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK; + case "Omnibox": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX; + case "PreloadedSites": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_PRELOADED; + case "TokenAliasEngines": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE; + case "UrlbarProviderSearchTips": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP; + case "HistoryUrlHeuristic": + return UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL; + default: + if (result.providerName.startsWith("TestProvider")) { + return UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST; + } + break; + } + if (result.providerType == UrlbarUtils.PROVIDER_TYPE.EXTENSION) { + return UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION; + } + console.error( + "Returning HEURISTIC_FALLBACK for unrecognized heuristic result: ", + result + ); + return UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK; + } + + switch (result.providerName) { + case "AboutPages": + return UrlbarUtils.RESULT_GROUP.ABOUT_PAGES; + case "InputHistory": + return UrlbarUtils.RESULT_GROUP.INPUT_HISTORY; + case "PreloadedSites": + return UrlbarUtils.RESULT_GROUP.PRELOADED; + case "UrlbarProviderQuickSuggest": + return UrlbarUtils.RESULT_GROUP.GENERAL_PARENT; + default: + break; + } + + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { + return UrlbarUtils.RESULT_GROUP.FORM_HISTORY; + } + if (result.payload.tail) { + return UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION; + } + if (result.payload.suggestion) { + return UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION; + } + break; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return UrlbarUtils.RESULT_GROUP.OMNIBOX; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return UrlbarUtils.RESULT_GROUP.REMOTE_TAB; + } + return UrlbarUtils.RESULT_GROUP.GENERAL; + }, + + /** + * Extracts an url from a result, if possible. + * + * @param {UrlbarResult} result The result to extract from. + * @returns {object} a {url, postData} object, or null if a url can't be built + * from this result. + */ + getUrlFromResult(result) { + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.URL: + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + return { url: result.payload.url, postData: null }; + case UrlbarUtils.RESULT_TYPE.KEYWORD: + return { + url: result.payload.url, + postData: result.payload.postData + ? this.getPostDataStream(result.payload.postData) + : null, + }; + case UrlbarUtils.RESULT_TYPE.SEARCH: { + if (result.payload.engine) { + const engine = Services.search.getEngineByName(result.payload.engine); + let [url, postData] = this.getSearchQueryUrl( + engine, + result.payload.suggestion || result.payload.query + ); + return { url, postData }; + } + break; + } + } + return { url: null, postData: null }; + }, + + /** + * Get the url to load for the search query. + * + * @param {nsISearchEngine} engine + * The engine to generate the query for. + * @param {string} query + * The query string to search for. + * @returns {Array} + * Returns an array containing the query url (string) and the + * post data (object). + */ + getSearchQueryUrl(engine, query) { + let submission = engine.getSubmission(query, null, "keyword"); + return [submission.uri.spec, submission.postData]; + }, + + // Ranks a URL prefix from 3 - 0 with the following preferences: + // https:// > https://www. > http:// > http://www. + // Higher is better for the purposes of deduping URLs. + // Returns -1 if the prefix does not match any of the above. + getPrefixRank(prefix) { + return ["http://www.", "http://", "https://www.", "https://"].indexOf( + prefix + ); + }, + + /** + * Get the number of rows a result should span in the autocomplete dropdown. + * + * @param {UrlbarResult} result The result being created. + * @returns {number} + * The number of rows the result should span in the autocomplete + * dropdown. + */ + getSpanForResult(result) { + if (result.resultSpan) { + return result.resultSpan; + } + + // We know this result will be hidden in the final view so assign it + // a span of zero. + if (result.exposureResultHidden) { + return 0; + } + + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.URL: + case UrlbarUtils.RESULT_TYPE.BOOKMARKS: + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + case UrlbarUtils.RESULT_TYPE.KEYWORD: + case UrlbarUtils.RESULT_TYPE.SEARCH: + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return 1; + case UrlbarUtils.RESULT_TYPE.TIP: + return 3; + } + return 1; + }, + + /** + * Gets a default icon for a URL. + * + * @param {string} url + * The URL to get the icon for. + * @returns {string} A URI pointing to an icon for `url`. + */ + getIconForUrl(url) { + if (typeof url == "string") { + return UrlbarUtils.PROTOCOLS_WITH_ICONS.some(p => url.startsWith(p)) + ? "page-icon:" + url + : UrlbarUtils.ICON.DEFAULT; + } + if ( + URL.isInstance(url) && + UrlbarUtils.PROTOCOLS_WITH_ICONS.includes(url.protocol) + ) { + return "page-icon:" + url.href; + } + return UrlbarUtils.ICON.DEFAULT; + }, + + /** + * Returns a search mode object if a token should enter search mode when + * typed. This does not handle engine aliases. + * + * @param {UrlbarUtils.RESTRICT} token + * A restriction token to convert to search mode. + * @returns {object} + * A search mode object. Null if search mode should not be entered. See + * setSearchMode documentation for details. + */ + searchModeForToken(token) { + if (token == lazy.UrlbarTokenizer.RESTRICT.SEARCH) { + return { + engineName: lazy.UrlbarSearchUtils.getDefaultEngine(this.isPrivate) + ?.name, + }; + } + + let mode = UrlbarUtils.LOCAL_SEARCH_MODES.find(m => m.restrict == token); + if (!mode) { + return null; + } + + // Return a copy so callers don't modify the object in LOCAL_SEARCH_MODES. + return { ...mode }; + }, + + /** + * Tries to initiate a speculative connection to a given url. + * + * Note: This is not infallible, if a speculative connection cannot be + * initialized, it will be a no-op. + * + * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate + * a speculative connection for. + * @param {window} window the window from where the connection is initialized. + */ + setupSpeculativeConnection(urlOrEngine, window) { + if (!lazy.UrlbarPrefs.get("speculativeConnect.enabled")) { + return; + } + if (urlOrEngine instanceof Ci.nsISearchEngine) { + try { + urlOrEngine.speculativeConnect({ + window, + originAttributes: window.gBrowser.contentPrincipal.originAttributes, + }); + } catch (ex) { + // Can't setup speculative connection for this url, just ignore it. + } + return; + } + + if (URL.isInstance(urlOrEngine)) { + urlOrEngine = urlOrEngine.href; + } + + try { + let uri = + urlOrEngine instanceof Ci.nsIURI + ? urlOrEngine + : Services.io.newURI(urlOrEngine); + Services.io.speculativeConnect( + uri, + window.gBrowser.contentPrincipal, + null, + false + ); + } catch (ex) { + // Can't setup speculative connection for this url, just ignore it. + } + }, + + /** + * Strips parts of a URL defined in `options`. + * + * @param {string} spec + * The text to modify. + * @param {object} [options] + * The options object. + * @param {boolean} options.stripHttp + * Whether to strip http. + * @param {boolean} options.stripHttps + * Whether to strip https. + * @param {boolean} options.stripWww + * Whether to strip `www.`. + * @param {boolean} options.trimSlash + * Whether to trim the trailing slash. + * @param {boolean} options.trimEmptyQuery + * Whether to trim a trailing `?`. + * @param {boolean} options.trimEmptyHash + * Whether to trim a trailing `#`. + * @param {boolean} options.trimTrailingDot + * Whether to trim a trailing '.'. + * @returns {string[]} [modified, prefix, suffix] + * modified: {string} The modified spec. + * prefix: {string} The parts stripped from the prefix, if any. + * suffix: {string} The parts trimmed from the suffix, if any. + */ + stripPrefixAndTrim(spec, options = {}) { + let prefix = ""; + let suffix = ""; + if (options.stripHttp && spec.startsWith("http://")) { + spec = spec.slice(7); + prefix = "http://"; + } else if (options.stripHttps && spec.startsWith("https://")) { + spec = spec.slice(8); + prefix = "https://"; + } + if (options.stripWww && spec.startsWith("www.")) { + spec = spec.slice(4); + prefix += "www."; + } + if (options.trimEmptyHash && spec.endsWith("#")) { + spec = spec.slice(0, -1); + suffix = "#" + suffix; + } + if (options.trimEmptyQuery && spec.endsWith("?")) { + spec = spec.slice(0, -1); + suffix = "?" + suffix; + } + if (options.trimSlash && spec.endsWith("/")) { + spec = spec.slice(0, -1); + suffix = "/" + suffix; + } + if (options.trimTrailingDot && spec.endsWith(".")) { + spec = spec.slice(0, -1); + suffix = "." + suffix; + } + return [spec, prefix, suffix]; + }, + + /** + * Strips a PSL verified public suffix from an hostname. + * + * Note: Because stripping the full suffix requires to verify it against the + * Public Suffix List, this call is not the cheapest, and thus it should + * not be used in hot paths. + * + * @param {string} host A host name. + * @returns {string} Host name without the public suffix. + */ + stripPublicSuffixFromHost(host) { + try { + return host.substring( + 0, + host.length - Services.eTLD.getKnownPublicSuffixFromHost(host).length + ); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS) { + throw ex; + } + } + return host; + }, + + /** + * Used to filter out the javascript protocol from URIs, since we don't + * support LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL for those. + * + * @param {string} pasteData The data to check for javacript protocol. + * @returns {string} The modified paste data. + */ + stripUnsafeProtocolOnPaste(pasteData) { + while (true) { + let scheme = ""; + try { + scheme = Services.io.extractScheme(pasteData); + } catch (ex) { + // If it throws, this is not a javascript scheme. + } + if (scheme != "javascript") { + break; + } + + pasteData = pasteData.substring(pasteData.indexOf(":") + 1); + } + return pasteData; + }, + + async addToInputHistory(url, input) { + await lazy.PlacesUtils.withConnectionWrapper("addToInputHistory", db => { + // use_count will asymptotically approach the max of 10. + return db.executeCached( + ` + INSERT OR REPLACE INTO moz_inputhistory + SELECT h.id, IFNULL(i.input, :input), IFNULL(i.use_count, 0) * .9 + 1 + FROM moz_places h + LEFT JOIN moz_inputhistory i ON i.place_id = h.id AND i.input = :input + WHERE url_hash = hash(:url) AND url = :url + `, + { url, input: input.toLowerCase() } + ); + }); + }, + + /** + * Whether the passed-in input event is paste event. + * + * @param {DOMEvent} event an input DOM event. + * @returns {boolean} Whether the event is a paste event. + */ + isPasteEvent(event) { + return ( + event.inputType && + (event.inputType.startsWith("insertFromPaste") || + event.inputType == "insertFromYank") + ); + }, + + /** + * Given a string, checks if it looks like a single word host, not containing + * spaces nor dots (apart from a possible trailing one). + * + * Note: This matching should stay in sync with the related code in + * URIFixup::KeywordURIFixup + * + * @param {string} value + * The string to check. + * @returns {boolean} + * Whether the value looks like a single word host. + */ + looksLikeSingleWordHost(value) { + let str = value.trim(); + return this.REGEXP_SINGLE_WORD.test(str); + }, + + /** + * Returns the portion of a string starting at the index where another string + * begins. + * + * @param {string} sourceStr + * The string to search within. + * @param {string} targetStr + * The string to search for. + * @returns {string} The substring within sourceStr starting at targetStr, or + * the empty string if targetStr does not occur in sourceStr. + */ + substringAt(sourceStr, targetStr) { + let index = sourceStr.indexOf(targetStr); + return index < 0 ? "" : sourceStr.substr(index); + }, + + /** + * Returns the portion of a string starting at the index where another string + * ends. + * + * @param {string} sourceStr + * The string to search within. + * @param {string} targetStr + * The string to search for. + * @returns {string} The substring within sourceStr where targetStr ends, or + * the empty string if targetStr does not occur in sourceStr. + */ + substringAfter(sourceStr, targetStr) { + let index = sourceStr.indexOf(targetStr); + return index < 0 ? "" : sourceStr.substr(index + targetStr.length); + }, + + /** + * Strips the prefix from a URL and returns the prefix and the remainder of + * the URL. "Prefix" is defined to be the scheme and colon plus zero to two + * slashes (see `UrlbarTokenizer.REGEXP_PREFIX`). If the given string is not + * actually a URL or it has a prefix we don't recognize, then an empty prefix + * and the string itself is returned. + * + * @param {string} str The possible URL to strip. + * @returns {Array} If `str` is a URL with a prefix we recognize, + * then [prefix, remainder]. Otherwise, ["", str]. + */ + stripURLPrefix(str) { + let match = lazy.UrlbarTokenizer.REGEXP_PREFIX.exec(str); + if (!match) { + return ["", str]; + } + let prefix = match[0]; + if (prefix.length < str.length && str[prefix.length] == " ") { + // A space following a prefix: + // e.g. "http:// some search string", "about: some search string" + return ["", str]; + } + if ( + prefix.endsWith(":") && + !UrlbarUtils.PROTOCOLS_WITHOUT_AUTHORITY.includes(prefix.toLowerCase()) + ) { + // Something that looks like a URI scheme but we won't treat as one: + // e.g. "localhost:8888" + return ["", str]; + } + return [prefix, str.substring(prefix.length)]; + }, + + /** + * Runs a search for the given string, and returns the heuristic result. + * + * @param {string} searchString The string to search for. + * @param {nsIDOMWindow} window The window requesting it. + * @returns {UrlbarResult} an heuristic result. + */ + async getHeuristicResultFor(searchString, window) { + if (!searchString) { + throw new Error("Must pass a non-null search string"); + } + + let options = { + allowAutofill: false, + isPrivate: lazy.PrivateBrowsingUtils.isWindowPrivate(window), + maxResults: 1, + searchString, + userContextId: + window.gBrowser.selectedBrowser.getAttribute("usercontextid"), + prohibitRemoteResults: true, + providers: ["AliasEngines", "BookmarkKeywords", "HeuristicFallback"], + }; + if (window.gURLBar.searchMode) { + let searchMode = window.gURLBar.searchMode; + options.searchMode = searchMode; + if (searchMode.source) { + options.sources = [searchMode.source]; + } + } + let context = new UrlbarQueryContext(options); + await lazy.UrlbarProvidersManager.startQuery(context); + if (!context.heuristicResult) { + throw new Error("There should always be an heuristic result"); + } + return context.heuristicResult; + }, + + /** + * Creates a logger. + * Logging level can be controlled through browser.urlbar.loglevel. + * + * @param {string} [prefix] Prefix to use for the logged messages, "::" will + * be appended automatically to the prefix. + * @returns {object} The logger. + */ + getLogger({ prefix = "" } = {}) { + if (!this._logger) { + this._logger = lazy.Log.repository.getLogger("urlbar"); + this._logger.manageLevelFromPref("browser.urlbar.loglevel"); + this._logger.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + } + if (prefix) { + // This is not an early return because it is necessary to invoke getLogger + // at least once before getLoggerWithMessagePrefix; it replaces a + // method of the original logger, rather than using an actual Proxy. + return lazy.Log.repository.getLoggerWithMessagePrefix( + "urlbar", + prefix + " :: " + ); + } + return this._logger; + }, + + /** + * Returns the name of a result source. The name is the lowercase name of the + * corresponding property in the RESULT_SOURCE object. + * + * @param {string} source A UrlbarUtils.RESULT_SOURCE value. + * @returns {string} The token's name, a lowercased name in the RESULT_SOURCE + * object. + */ + getResultSourceName(source) { + if (!this._resultSourceNamesBySource) { + this._resultSourceNamesBySource = new Map(); + for (let [name, src] of Object.entries(this.RESULT_SOURCE)) { + this._resultSourceNamesBySource.set(src, name.toLowerCase()); + } + } + return this._resultSourceNamesBySource.get(source); + }, + + /** + * Add the search to form history. This also updates any existing form + * history for the search. + * + * @param {UrlbarInput} input The UrlbarInput object requesting the addition. + * @param {string} value The value to add. + * @param {string} [source] The source of the addition, usually + * the name of the engine the search was made with. + * @returns {Promise} resolved once the operation is complete + */ + addToFormHistory(input, value, source) { + // If the user types a search engine alias without a search string, + // we have an empty search string and we can't bump it. + // We also don't want to add history in private browsing mode. + // Finally we don't want to store extremely long strings that would not be + // particularly useful to the user. + if ( + !value || + input.isPrivate || + value.length > + lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) { + return Promise.resolve(); + } + return lazy.FormHistory.update({ + op: "bump", + fieldname: input.formHistoryName, + value, + source, + }); + }, + + /** + * Returns whether a URL can be autofilled from a candidate string. This + * function is specifically designed for origin and up-to-the-next-slash URL + * autofill. It should not be used for other types of autofill. + * + * @param {string} url + * The URL to test + * @param {string} candidate + * The candidate string to test against + * @param {string} checkFragmentOnly + * If want to check the fragment only, pass true. + * Otherwise, check whole url. + * @returns {boolean} true: can autofill + */ + canAutofillURL(url, candidate, checkFragmentOnly = false) { + // If the URL does not start with the candidate, it can't be autofilled. + // The length check is an optimization to short-circuit the `startsWith()`. + if ( + !checkFragmentOnly && + (url.length <= candidate.length || + !url.toLocaleLowerCase().startsWith(candidate.toLocaleLowerCase())) + ) { + return false; + } + + // Create `URL` objects to make the logic below easier. The strings must + // include schemes for this to work. + if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(url)) { + url = "http://" + url; + } + if (!lazy.UrlbarTokenizer.REGEXP_PREFIX.test(candidate)) { + candidate = "http://" + candidate; + } + try { + url = new URL(url); + candidate = new URL(candidate); + } catch (e) { + return false; + } + + if (checkFragmentOnly) { + return url.hash.startsWith(candidate.hash); + } + + // For both origin and URL autofill, autofill should stop when the user + // types a trailing slash. This is a fundamental part of autofill's + // up-to-the-next-slash behavior. We handle that here in the else-if branch. + // The length and hash checks in the else-if condition aren't strictly + // necessary -- the else-if branch could simply be an else-branch that + // returns false -- but they mean this function will return true when the + // URL and candidate have the same case-insenstive path and no hash. In + // other words, we allow a URL to autofill itself. + if (!candidate.href.endsWith("/")) { + // The candidate doesn't end in a slash. The URL can't be autofilled if + // its next slash is not at the end. + let nextSlashIndex = url.pathname.indexOf("/", candidate.pathname.length); + if (nextSlashIndex >= 0 && nextSlashIndex != url.pathname.length - 1) { + return false; + } + } else if (url.pathname.length > candidate.pathname.length || url.hash) { + return false; + } + + return url.hash.startsWith(candidate.hash); + }, + + /** + * Extracts a telemetry type from a result, used by scalars and event + * telemetry. + * + * Note: New types should be added to Scalars.yaml under the urlbar.picked + * category and documented in the in-tree documentation. A data-review + * is always necessary. + * + * @param {UrlbarResult} result The result to analyze. + * @returns {string} A string type for telemetry. + */ + telemetryTypeFromResult(result) { + if (!result) { + return "unknown"; + } + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + return "switchtab"; + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { + return "formhistory"; + } + if (result.providerName == "TabToSearch") { + return "tabtosearch"; + } + if (result.payload.suggestion) { + let type = result.payload.trending ? "trending" : "searchsuggestion"; + if (result.payload.isRichSuggestion) { + type += "_rich"; + } + return type; + } + return "searchengine"; + case UrlbarUtils.RESULT_TYPE.URL: + if (result.autofill) { + let { type } = result.autofill; + if (!type) { + type = "other"; + console.error( + new Error( + "`result.autofill.type` not set, falling back to 'other'" + ) + ); + } + return `autofill_${type}`; + } + if ( + result.source == UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && + result.heuristic + ) { + return "visiturl"; + } + if (result.providerName == "UrlbarProviderQuickSuggest") { + // Don't add any more `urlbar.picked` legacy telemetry if possible! + // Return "quicksuggest" here and rely on Glean instead. + switch (result.payload.telemetryType) { + case "top_picks": + return "navigational"; + case "wikipedia": + return "dynamic_wikipedia"; + } + return "quicksuggest"; + } + return result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS + ? "bookmark" + : "history"; + case UrlbarUtils.RESULT_TYPE.KEYWORD: + return "keyword"; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return "extension"; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return "remotetab"; + case UrlbarUtils.RESULT_TYPE.TIP: + return "tip"; + case UrlbarUtils.RESULT_TYPE.DYNAMIC: + if (result.providerName == "TabToSearch") { + // This is the onboarding result. + return "tabtosearch"; + } else if (result.providerName == "quickactions") { + return "quickaction"; + } else if (result.providerName == "Weather") { + return "weather"; + } + return "dynamic"; + } + return "unknown"; + }, + + /** + * Unescape the given uri to use as UI. + * NOTE: If the length of uri is over MAX_TEXT_LENGTH, + * return the given uri as it is. + * + * @param {string} uri will be unescaped. + * @returns {string} Unescaped uri. + */ + unEscapeURIForUI(uri) { + return uri.length > UrlbarUtils.MAX_TEXT_LENGTH + ? uri + : Services.textToSubURI.unEscapeURIForUI(uri); + }, + + /** + * Extracts a group for search engagement telemetry from a result. + * + * @param {UrlbarResult} result The result to analyze. + * @returns {string} Group name as string. + */ + searchEngagementTelemetryGroup(result) { + if (!result) { + return "unknown"; + } + if (result.isBestMatch) { + return "top_pick"; + } + if (result.providerName === "UrlbarProviderTopSites") { + return "top_site"; + } + + switch (this.getResultGroup(result)) { + case UrlbarUtils.RESULT_GROUP.INPUT_HISTORY: { + return "adaptive_history"; + } + case UrlbarUtils.RESULT_GROUP.FORM_HISTORY: { + return "search_history"; + } + case UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION: + case UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION: { + let group = result.payload.trending + ? "trending_search" + : "search_suggest"; + if (result.payload.isRichSuggestion) { + group += "_rich"; + } + return group; + } + case UrlbarUtils.RESULT_GROUP.REMOTE_TAB: { + return "remote_tab"; + } + case UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION: + case UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX: + case UrlbarUtils.RESULT_GROUP.OMNIBOX: { + return "addon"; + } + case UrlbarUtils.RESULT_GROUP.GENERAL: { + return "general"; + } + // Group of UrlbarProviderQuickSuggest is GENERAL_PARENT. + case UrlbarUtils.RESULT_GROUP.GENERAL_PARENT: { + return "suggest"; + } + case UrlbarUtils.RESULT_GROUP.ABOUT_PAGES: { + return "about_page"; + } + case UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX: { + return "suggested_index"; + } + } + + return result.heuristic ? "heuristic" : "unknown"; + }, + + /** + * Extracts a type for search engagement telemetry from a result. + * + * @param {UrlbarResult} result The result to analyze. + * @param {string} selType An optional parameter for the selected type. + * @returns {string} Type as string. + */ + searchEngagementTelemetryType(result, selType = null) { + if (!result) { + return selType === "oneoff" ? "search_shortcut_button" : "input_field"; + } + + if ( + result.providerType === UrlbarUtils.PROVIDER_TYPE.EXTENSION && + result.providerName != "Omnibox" + ) { + return "experimental_addon"; + } + + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.DYNAMIC: + switch (result.providerName) { + case "calculator": + return "calc"; + case "quickactions": + return "action"; + case "TabToSearch": + return "tab_to_search"; + case "UnitConversion": + return "unit"; + case "UrlbarProviderContextualSearch": + return "site_specific_contextual_search"; + case "UrlbarProviderQuickSuggest": + return this._getQuickSuggestTelemetryType(result); + case "Weather": + return "weather"; + } + break; + case UrlbarUtils.RESULT_TYPE.KEYWORD: + return "keyword"; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return "addon"; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return "remote_tab"; + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.providerName === "TabToSearch") { + return "tab_to_search"; + } + if (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY) { + return "search_history"; + } + if (result.payload.suggestion) { + let type = result.payload.trending + ? "trending_search" + : "search_suggest"; + if (result.payload.isRichSuggestion) { + type += "_rich"; + } + return type; + } + return "search_engine"; + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + return "tab"; + case UrlbarUtils.RESULT_TYPE.TIP: + if (result.providerName === "UrlbarProviderInterventions") { + switch (result.payload.type) { + case lazy.UrlbarProviderInterventions.TIP_TYPE.CLEAR: + return "intervention_clear"; + case lazy.UrlbarProviderInterventions.TIP_TYPE.REFRESH: + return "intervention_refresh"; + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_ASK: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_CHECKING: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_REFRESH: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_RESTART: + case lazy.UrlbarProviderInterventions.TIP_TYPE.UPDATE_WEB: + return "intervention_update"; + default: + return "intervention_unknown"; + } + } + + switch (result.payload.type) { + case lazy.UrlbarProviderSearchTips.TIP_TYPE.ONBOARD: + return "tip_onboard"; + case lazy.UrlbarProviderSearchTips.TIP_TYPE.PERSIST: + return "tip_persist"; + case lazy.UrlbarProviderSearchTips.TIP_TYPE.REDIRECT: + return "tip_redirect"; + case "dismissalAcknowledgment": + return "tip_dismissal_acknowledgment"; + default: + return "tip_unknown"; + } + case UrlbarUtils.RESULT_TYPE.URL: + if ( + result.source === UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && + result.heuristic + ) { + return "url"; + } + if (result.autofill) { + return `autofill_${result.autofill.type ?? "unknown"}`; + } + if (result.providerName === "UrlbarProviderQuickSuggest") { + return this._getQuickSuggestTelemetryType(result); + } + if (result.providerName === "UrlbarProviderTopSites") { + return "top_site"; + } + return result.source === UrlbarUtils.RESULT_SOURCE.BOOKMARKS + ? "bookmark" + : "history"; + } + + return "unknown"; + }, + + /** + * Extracts a subtype for search engagement telemetry from a result and the picked element. + * + * @param {UrlbarResult} result The result to analyze. + * @param {DOMElement} element The picked view element. Nullable. + * @returns {string} Subtype as string. + */ + searchEngagementTelemetrySubtype(result, element) { + if (!result) { + return ""; + } + + if ( + result.providerName === "quickactions" && + element?.classList.contains("urlbarView-quickaction-row") + ) { + return element.dataset.key; + } + + return ""; + }, + + _getQuickSuggestTelemetryType(result) { + let source = result.payload.source; + if (source == "remote-settings") { + source = "rs"; + } + return `${source}_${result.payload.telemetryType}`; + }, +}; + +XPCOMUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => { + return lazy.PlacesUtils.favicons.defaultFavicon.spec; +}); + +XPCOMUtils.defineLazyGetter(UrlbarUtils, "strings", () => { + return Services.strings.createBundle( + "chrome://global/locale/autocomplete.properties" + ); +}); + +/** + * Payload JSON schemas for each result type. Payloads are validated against + * these schemas using JsonSchemaValidator.sys.mjs. + */ +UrlbarUtils.RESULT_PAYLOAD_SCHEMA = { + [UrlbarUtils.RESULT_TYPE.TAB_SWITCH]: { + type: "object", + required: ["url"], + properties: { + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + userContextId: { + type: "number", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.SEARCH]: { + type: "object", + properties: { + description: { + type: "string", + }, + displayUrl: { + type: "string", + }, + engine: { + type: "string", + }, + icon: { + type: "string", + }, + inPrivateWindow: { + type: "boolean", + }, + isPinned: { + type: "boolean", + }, + isPrivateEngine: { + type: "boolean", + }, + isGeneralPurposeEngine: { + type: "boolean", + }, + isRichSuggestion: { + type: "boolean", + }, + keyword: { + type: "string", + }, + lowerCaseSuggestion: { + type: "string", + }, + providesSearchMode: { + type: "boolean", + }, + query: { + type: "string", + }, + satisfiesAutofillThreshold: { + type: "boolean", + }, + suggestion: { + type: "string", + }, + tail: { + type: "string", + }, + tailPrefix: { + type: "string", + }, + tailOffsetIndex: { + type: "number", + }, + title: { + type: "string", + }, + trending: { + type: "boolean", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.URL]: { + type: "object", + required: ["url"], + properties: { + // l10n { id, args } + blockL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + displayUrl: { + type: "string", + }, + dupedHeuristic: { + type: "boolean", + }, + fallbackTitle: { + type: "string", + }, + // l10n { id, args } + helpL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + helpUrl: { + type: "string", + }, + icon: { + type: "string", + }, + isBlockable: { + type: "boolean", + }, + isPinned: { + type: "boolean", + }, + isSponsored: { + type: "boolean", + }, + originalUrl: { + type: "string", + }, + qsSuggestion: { + type: "string", + }, + requestId: { + type: "string", + }, + sendAttributionRequest: { + type: "boolean", + }, + source: { + type: "string", + }, + sponsoredAdvertiser: { + type: "string", + }, + sponsoredBlockId: { + type: "number", + }, + sponsoredClickUrl: { + type: "string", + }, + sponsoredIabCategory: { + type: "string", + }, + sponsoredImpressionUrl: { + type: "string", + }, + sponsoredTileId: { + type: "number", + }, + subtype: { + type: "string", + }, + tags: { + type: "array", + items: { + type: "string", + }, + }, + telemetryType: { + type: "string", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + urlTimestampIndex: { + type: "number", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.KEYWORD]: { + type: "object", + required: ["keyword", "url"], + properties: { + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + input: { + type: "string", + }, + keyword: { + type: "string", + }, + postData: { + type: "string", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.OMNIBOX]: { + type: "object", + required: ["keyword"], + properties: { + blockL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + content: { + type: "string", + }, + icon: { + type: "string", + }, + isBlockable: { + type: "boolean", + }, + keyword: { + type: "string", + }, + title: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: { + type: "object", + required: ["device", "url", "lastUsed"], + properties: { + device: { + type: "string", + }, + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + lastUsed: { + type: "number", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.TIP]: { + type: "object", + required: ["type"], + properties: { + buttons: { + type: "array", + items: { + type: "object", + required: ["l10n"], + properties: { + l10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + url: { + type: "string", + }, + }, + }, + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + buttonText: { + type: "string", + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + buttonUrl: { + type: "string", + }, + // l10n { id, args } + helpL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + helpUrl: { + type: "string", + }, + icon: { + type: "string", + }, + // TODO: This is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + text: { + type: "string", + }, + // l10n { id, args } + titleL10n: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + // `type` is used in the names of keys in the `urlbar.tips` keyed scalar + // telemetry (see telemetry.rst). If you add a new type, then you are + // also adding new `urlbar.tips` keys and therefore need an expanded data + // collection review. + type: { + type: "string", + enum: [ + "dismissalAcknowledgment", + "extension", + "intervention_clear", + "intervention_refresh", + "intervention_update_ask", + "intervention_update_refresh", + "intervention_update_restart", + "intervention_update_web", + "searchTip_onboard", + "searchTip_persist", + "searchTip_redirect", + "test", // for tests only + ], + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.DYNAMIC]: { + type: "object", + required: ["dynamicType"], + properties: { + dynamicType: { + type: "string", + }, + // If `shouldNavigate` is `true` and the payload contains a `url` + // property, when the result is selected the browser will navigate to the + // `url`. + shouldNavigate: { + type: "boolean", + }, + }, + }, +}; + +/** + * UrlbarQueryContext defines a user's autocomplete input from within the urlbar. + * It supplements it with details of how the search results should be obtained + * and what they consist of. + */ +export class UrlbarQueryContext { + /** + * Constructs the UrlbarQueryContext instance. + * + * @param {object} options + * The initial options for UrlbarQueryContext. + * @param {string} options.searchString + * The string the user entered in autocomplete. Could be the empty string + * in the case of the user opening the popup via the mouse. + * @param {boolean} options.isPrivate + * Set to true if this query was started from a private browsing window. + * @param {number} options.maxResults + * The maximum number of results that will be displayed for this query. + * @param {boolean} options.allowAutofill + * Whether or not to allow providers to include autofill results. + * @param {number} options.userContextId + * The container id where this context was generated, if any. + * @param {Array} [options.sources] + * A list of acceptable UrlbarUtils.RESULT_SOURCE for the context. + * @param {object} [options.searchMode] + * The input's current search mode. See UrlbarInput.setSearchMode for a + * description. + * @param {boolean} [options.prohibitRemoteResults] + * This provides a short-circuit override for `context.allowRemoteResults`. + * If it's false, then `allowRemoteResults` will do its usual checks to + * determine whether remote results are allowed. If it's true, then + * `allowRemoteResults` will immediately return false. Defaults to false. + * @param {string} [options.formHistoryName] + * The name under which the local form history is registered. + */ + constructor(options = {}) { + this._checkRequiredOptions(options, [ + "allowAutofill", + "isPrivate", + "maxResults", + "searchString", + ]); + + if (isNaN(parseInt(options.maxResults))) { + throw new Error( + `Invalid maxResults property provided to UrlbarQueryContext` + ); + } + + // Manage optional properties of options. + for (let [prop, checkFn, defaultValue] of [ + ["currentPage", v => typeof v == "string" && !!v.length], + ["formHistoryName", v => typeof v == "string" && !!v.length], + ["prohibitRemoteResults", v => true, false], + ["providers", v => Array.isArray(v) && v.length], + ["searchMode", v => v && typeof v == "object"], + ["sources", v => Array.isArray(v) && v.length], + ["view", v => true], + ]) { + if (prop in options) { + if (!checkFn(options[prop])) { + throw new Error(`Invalid value for option "${prop}"`); + } + this[prop] = options[prop]; + } else if (defaultValue !== undefined) { + this[prop] = defaultValue; + } + } + + this.lastResultCount = 0; + // Note that Set is not serializable through JSON, so these may not be + // easily shared with add-ons. + this.pendingHeuristicProviders = new Set(); + this.deferUserSelectionProviders = new Set(); + this.trimmedSearchString = this.searchString.trim(); + this.userContextId = + options.userContextId || + Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; + } + + /** + * Checks the required options, saving them as it goes. + * + * @param {object} options The options object to check. + * @param {Array} optionNames The names of the options to check for. + * @throws {Error} Throws if there is a missing option. + */ + _checkRequiredOptions(options, optionNames) { + for (let optionName of optionNames) { + if (!(optionName in options)) { + throw new Error( + `Missing or empty ${optionName} provided to UrlbarQueryContext` + ); + } + this[optionName] = options[optionName]; + } + } + + /** + * Caches and returns fixup info from URIFixup for the current search string. + * Only returns a subset of the properties from URIFixup. This is both to + * reduce the memory footprint of UrlbarQueryContexts and to keep them + * serializable so they can be sent to extensions. + * + * @returns {{ href: string; isSearch: boolean; }?} + */ + get fixupInfo() { + if (this.trimmedSearchString && !this._fixupInfo) { + let flags = + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (this.isPrivate) { + flags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + + try { + let info = Services.uriFixup.getFixupURIInfo( + this.trimmedSearchString, + flags + ); + + this._fixupInfo = { + href: info.fixedURI.spec, + isSearch: !!info.keywordAsSent, + scheme: info.fixedURI.scheme, + }; + } catch (ex) { + this._fixupError = ex.result; + } + } + + return this._fixupInfo || null; + } + + /** + * Returns the error that was thrown when fixupInfo was fetched, if any. If + * fixupInfo has not yet been fetched for this queryContext, it is fetched + * here. + * + * @returns {any?} + */ + get fixupError() { + if (!this.fixupInfo) { + return this._fixupError; + } + + return null; + } + + /** + * Returns whether results from remote services are generally allowed for the + * context. Callers can impose further restrictions as appropriate, but + * typically they should not fetch remote results if this returns false. + * + * @param {string} [searchString] + * Usually this is just the context's search string, but if you need to + * fetch remote results based on a modified version, you can pass it here. + * @param {boolean} [allowEmptySearchString] + * Whether to check for the minimum length of the search string. + * @returns {boolean} + * Whether remote results are allowed. + */ + allowRemoteResults( + searchString = this.searchString, + allowEmptySearchString = false + ) { + if (this.prohibitRemoteResults) { + return false; + } + + // We're unlikely to get useful remote results for a single character. + if (searchString.length < 2 && !allowEmptySearchString) { + return false; + } + + // Disallow remote results if only an origin is typed to avoid disclosing + // sites the user visits. This also catches partially typed origins, like + // mozilla.o, because the fixup check below can't validate them. + if ( + this.tokens.length == 1 && + this.tokens[0].type == lazy.UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN + ) { + return false; + } + + // Disallow remote results for strings containing tokens that look like URIs + // to avoid disclosing information about networks and passwords. + if (this.fixupInfo?.href && !this.fixupInfo?.isSearch) { + return false; + } + + // Allow remote results. + return true; + } +} + +/** + * Base class for a muxer. + * The muxer scope is to sort a given list of results. + */ +export class UrlbarMuxer { + /** + * Unique name for the muxer, used by the context to sort results. + * Not using a unique name will cause the newest registration to win. + * + * @abstract + */ + get name() { + return "UrlbarMuxerBase"; + } + + /** + * Sorts queryContext results in-place. + * + * @param {UrlbarQueryContext} queryContext the context to sort results for. + * @abstract + */ + sort(queryContext) { + throw new Error("Trying to access the base class, must be overridden"); + } +} + +/** + * Base class for a provider. + * The provider scope is to query a datasource and return results from it. + */ +export class UrlbarProvider { + constructor() { + XPCOMUtils.defineLazyGetter(this, "logger", () => + UrlbarUtils.getLogger({ prefix: `Provider.${this.name}` }) + ); + } + + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + * + * @abstract + */ + get name() { + return "UrlbarProviderBase"; + } + + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * + * @abstract + */ + get type() { + throw new Error("Trying to access the base class, must be overridden"); + } + + /** + * Calls a method on the provider in a try-catch block and reports any error. + * Unlike most other provider methods, `tryMethod` is not intended to be + * overridden. + * + * @param {string} methodName The name of the method to call. + * @param {*} args The method arguments. + * @returns {*} The return value of the method, or undefined if the method + * throws an error. + * @abstract + */ + tryMethod(methodName, ...args) { + try { + return this[methodName](...args); + } catch (ex) { + console.error(ex); + } + return undefined; + } + + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + * @abstract + */ + isActive(queryContext) { + throw new Error("Trying to access the base class, must be overridden"); + } + + /** + * Gets the provider's priority. Priorities are numeric values starting at + * zero and increasing in value. Smaller values are lower priorities, and + * larger values are higher priorities. For a given query, `startQuery` is + * called on only the active and highest-priority providers. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + * @abstract + */ + getPriority(queryContext) { + // By default, all providers share the lowest priority. + return 0; + } + + /** + * Starts querying. + * + * Note: Extended classes should return a Promise resolved when the provider + * is done searching AND returning results. + * + * @param {UrlbarQueryContext} queryContext The query context object + * @param {Function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @abstract + */ + startQuery(queryContext, addCallback) { + throw new Error("Trying to access the base class, must be overridden"); + } + + /** + * Cancels a running query, + * + * @param {UrlbarQueryContext} queryContext the query context object to cancel + * query for. + * @abstract + */ + cancelQuery(queryContext) { + // Override this with your clean-up on cancel code. + } + + /** + * Called when the user starts and ends an engagement with the urlbar. + * + * @param {boolean} isPrivate + * True if the engagement is in a private context. + * @param {string} state + * The state of the engagement, one of the following strings: + * + * start + * A new query has started in the urlbar. + * engagement + * The user picked a result in the urlbar or used paste-and-go. + * abandonment + * The urlbar was blurred (i.e., lost focus). + * discard + * This doesn't correspond to a user action, but it means that the + * urlbar has discarded the engagement for some reason, and the + * `onEngagement` implementation should ignore it. + * + * @param {UrlbarQueryContext} queryContext + * The engagement's query context. This is *not* guaranteed to be defined + * when `state` is "start". It will always be defined for "engagement" and + * "abandonment". + * @param {object} details + * This object is non-empty only when `state` is "engagement" or + * "abandonment", and it describes the search string and engaged result. + * + * For "engagement", it has the following properties: + * + * {UrlbarResult} result + * The engaged result. If a result itself was picked, this will be it. + * If an element related to a result was picked (like a button or menu + * command), this will be that result. This property will be present if + * and only if `state` == "engagement", so it can be used to quickly + * tell when the user engaged with a result. + * {Element} element + * The picked DOM element. + * {boolean} isSessionOngoing + * True if the search session remains ongoing or false if the engagement + * ended it. Typically picking a result ends the session but not always. + * Picking a button or menu command may not end the session; dismissals + * do not, for example. + * {string} searchString + * The search string for the engagement's query. + * {number} selIndex + * The index of the picked result. + * {string} selType + * The type of the selected result. See TelemetryEvent.record() in + * UrlbarController.jsm. + * {string} provider + * The name of the provider that produced the picked result. + * + * For "abandonment", only `searchString` is defined. + */ + onEngagement(isPrivate, state, queryContext, details) {} + + /** + * Called when a result from the provider is selected. "Selected" refers to + * the user highlighing the result with the arrow keys/Tab, before it is + * picked. onSelection is also called when a user clicks a result. In the + * event of a click, onSelection is called just before onEngagement. Note that + * this is called when heuristic results are pre-selected. + * + * @param {UrlbarResult} result + * The result that was selected. + * @param {Element} element + * The element in the result's view that was selected. + * @abstract + */ + onSelection(result, element) {} + + /** + * This is called only for dynamic result types, when the urlbar view updates + * the view of one of the results of the provider. It should return an object + * describing the view update that looks like this: + * + * { + * nodeNameFoo: { + * attributes: { + * someAttribute: someValue, + * }, + * style: { + * someStyleProperty: someValue, + * }, + * l10n: { + * id: someL10nId, + * args: someL10nArgs, + * }, + * textContent: "some text content", + * }, + * nodeNameBar: { + * ... + * }, + * nodeNameBaz: { + * ... + * }, + * } + * + * The object should contain a property for each element to update in the + * dynamic result type view. The names of these properties are the names + * declared in the view template of the dynamic result type; see + * UrlbarView.addDynamicViewTemplate(). The values are similar to the nested + * objects specified in the view template but not quite the same; see below. + * For each property, the element in the view subtree with the specified name + * is updated according to the object in the property's value. If an + * element's name is not specified, then it will not be updated and will + * retain its current state. + * + * @param {UrlbarResult} result + * The result whose view will be updated. + * @param {Map} idsByName + * A Map from an element's name, as defined by the provider; to its ID in + * the DOM, as defined by the browser. The browser manages element IDs for + * dynamic results to prevent collisions. However, a provider may need to + * access the IDs of the elements created for its results. For example, to + * set various `aria` attributes. + * @returns {object} + * A view update object as described above. The names of properties are the + * the names of elements declared in the view template. The values of + * properties are objects that describe how to update each element, and + * these objects may include the following properties, all of which are + * optional: + * + * {object} [attributes] + * A mapping from attribute names to values. Each name-value pair results + * in an attribute being added to the element. The `id` attribute is + * reserved and cannot be set by the provider. + * {object} [style] + * A plain object that can be used to add inline styles to the element, + * like `display: none`. `element.style` is updated for each name-value + * pair in this object. + * {object} [l10n] + * An { id, args } object that will be passed to + * document.l10n.setAttributes(). + * {string} [textContent] + * A string that will be set as `element.textContent`. + */ + getViewUpdate(result, idsByName) { + return null; + } + + /** + * Gets the list of commands that should be shown in the result menu for a + * given result from the provider. All commands returned by this method should + * be handled by implementing `onEngagement()` with the possible exception of + * commands automatically handled by the urlbar, like "help". + * + * @param {UrlbarResult} result + * The menu will be shown for this result. + * @returns {Array} + * If the result doesn't have any commands, this should return null. + * Otherwise it should return an array of command objects that look like: + * `{ name, l10n, children}` + * + * {string} name + * The name of the command. Must be specified unless `children` is + * present. When a command is picked, its name will be passed as + * `details.selType` to `onEngagement()`. The special name "separator" + * will create a menu separator. + * {object} l10n + * An l10n object for the command's label: `{ id, args }` + * Must be specified unless `name` is "separator". + * {array} children + * If specified, a submenu will be created with the given child commands. + * Each object in the array must be a command object. + */ + getResultCommands(result) { + return null; + } + + /** + * Defines whether the view should defer user selection events while waiting + * for the first result from this provider. + * + * Note: UrlbarEventBufferer has a timeout after which user events will be + * processed regardless. + * + * @returns {boolean} Whether the provider wants to defer user selection + * events. + * @see {@link UrlbarEventBufferer} + */ + get deferUserSelection() { + return false; + } +} + +/** + * Class used to create a timer that can be manually fired, to immediately + * invoke the callback, or canceled, as necessary. + * Examples: + * let timer = new SkippableTimer(); + * // Invokes the callback immediately without waiting for the delay. + * await timer.fire(); + * // Cancel the timer, the callback won't be invoked. + * await timer.cancel(); + * // Wait for the timer to have elapsed. + * await timer.promise; + */ +export class SkippableTimer { + /** + * Creates a skippable timer for the given callback and time. + * + * @param {object} options An object that configures the timer + * @param {string} options.name The name of the timer, logged when necessary + * @param {Function} options.callback To be invoked when requested + * @param {number} options.time A delay in milliseconds to wait for + * @param {boolean} options.reportErrorOnTimeout If true and the timer times + * out, an error will be logged with Cu.reportError + * @param {logger} options.logger An optional logger + */ + constructor({ + name = "<anonymous timer>", + callback = null, + time = 0, + reportErrorOnTimeout = false, + logger = null, + } = {}) { + this.name = name; + this.logger = logger; + + let timerPromise = new Promise(resolve => { + this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + this._timer.initWithCallback( + () => { + this._log(`Timed out!`, reportErrorOnTimeout); + this._timer = null; + resolve(); + }, + time, + Ci.nsITimer.TYPE_ONE_SHOT + ); + this._log(`Started`); + }); + + let firePromise = new Promise(resolve => { + this.fire = async () => { + if (this._timer) { + if (!this._canceled) { + this._log(`Skipped`); + } + this._timer.cancel(); + this._timer = null; + resolve(); + } + await this.promise; + }; + }); + + this.promise = Promise.race([timerPromise, firePromise]).then(() => { + // If we've been canceled, don't call back. + if (callback && !this._canceled) { + callback(); + } + }); + } + + /** + * Allows to cancel the timer and the callback won't be invoked. + * It is not strictly necessary to await for this, the promise can just be + * used to ensure all the internal work is complete. + */ + async cancel() { + if (this._timer) { + this._log(`Canceling`); + this._canceled = true; + } + await this.fire(); + } + + _log(msg, isError = false) { + let line = `SkippableTimer :: ${this.name} :: ${msg}`; + if (this.logger) { + this.logger.debug(line); + } + if (isError) { + console.error(line); + } + } +} + +/** + * This class implements a cache for l10n strings. Cached strings can be + * accessed synchronously, avoiding the asynchronicity of `data-l10n-id` and + * `document.l10n.setAttributes`, which can lead to text pop-in and flickering + * as strings are fetched from Fluent. (`document.l10n.formatValueSync` is also + * sync but should not be used since it may perform sync I/O.) + * + * Values stored and returned by the cache are JS objects similar to + * `L10nMessage` objects, not bare strings. This allows the cache to store not + * only l10n strings with bare values but also strings that define attributes + * (e.g., ".label = My label value"). See `get` for details. + */ +export class L10nCache { + /** + * @param {Localization} l10n + * A `Localization` object like `document.l10n`. This class keeps a weak + * reference to this object, so the caller or something else must hold onto + * it. + */ + constructor(l10n) { + this.l10n = Cu.getWeakReference(l10n); + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + Services.obs.addObserver(this, "intl:app-locales-changed", true); + } + + /** + * Gets a cached l10n message. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true if the string was cached using a key that excluded arguments. + * @returns {object} + * The message object or undefined if it's not cached. The message object is + * similar to `L10nMessage` (defined in Localization.webidl) but its + * attributes are stored differently for convenience. It looks like this: + * + * { value, attributes } + * + * The properties are: + * + * {string} value + * The bare value of the string. If the string does not have a bare + * value (i.e., it has only attributes), this will be null. + * {object} attributes + * A mapping from attribute names to their values. If the string doesn't + * have any attributes, this will be null. + * + * For example, if we cache these strings from an ftl file: + * + * foo = Foo's value + * bar = + * .label = Bar's label value + * + * Then: + * + * cache.get("foo") + * // => { value: "Foo's value", attributes: null } + * cache.get("bar") + * // => { value: null, attributes: { label: "Bar's label value" }} + */ + get({ id, args = undefined, excludeArgsFromCacheKey = false }) { + return this._messagesByKey.get( + this._key({ id, args, excludeArgsFromCacheKey }) + ); + } + + /** + * Fetches a string from Fluent and caches it. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to cache the string using a key that excludes the arguments. + * The string will be cached only by its ID. This is useful if the string is + * used only once in the UI, its arguments vary, and it's acceptable to + * fetch and show a cached value with old arguments until the string is + * relocalized with new arguments. + */ + async add({ id, args = undefined, excludeArgsFromCacheKey = false }) { + let l10n = this.l10n.get(); + if (!l10n) { + return; + } + let messages = await l10n.formatMessages([{ id, args }]); + if (!messages?.length) { + console.error( + "l10n.formatMessages returned an unexpected value for ID: ", + id + ); + return; + } + let message = messages[0]; + if (message.attributes) { + // Convert `attributes` from an array of `{ name, value }` objects to one + // object mapping names to values. + message.attributes = message.attributes.reduce( + (valuesByName, { name, value }) => { + valuesByName[name] = value; + return valuesByName; + }, + {} + ); + } + this._messagesByKey.set( + this._key({ id, args, excludeArgsFromCacheKey }), + message + ); + } + + /** + * Fetches and caches a string if it's not already cached. This is just a + * slight optimization over `add` that avoids calling into Fluent + * unnecessarily. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to cache the string using a key that excludes the arguments. + * The string will be cached only by its ID. See `add()` for more. + */ + async ensure({ id, args = undefined, excludeArgsFromCacheKey = false }) { + // Always re-cache if `excludeArgsFromCacheKey` is true. The values in + // `args` may be different from the values in the cached string. + if (excludeArgsFromCacheKey || !this.get({ id, args })) { + await this.add({ id, args, excludeArgsFromCacheKey }); + } + } + + /** + * Fetches and caches strings that aren't already cached. + * + * @param {Array} objects + * An array of objects as passed to `ensure()`. + */ + async ensureAll(objects) { + let promises = []; + for (let obj of objects) { + promises.push(this.ensure(obj)); + } + await Promise.all(promises); + } + + /** + * Removes a cached string. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true if the string was cached using a key that excludes the + * arguments. If true, `args` is ignored. + */ + delete({ id, args = undefined, excludeArgsFromCacheKey = false }) { + this._messagesByKey.delete( + this._key({ id, args, excludeArgsFromCacheKey }) + ); + } + + /** + * Removes all cached strings. + */ + clear() { + this._messagesByKey.clear(); + } + + /** + * Returns the number of cached messages. + * + * @returns {number} + */ + size() { + return this._messagesByKey.size; + } + + /** + * Observer method from Services.obs.addObserver. + * + * @param {nsISupports} subject + * The subject of the notification. + * @param {string} topic + * The topic of the notification. + * @param {string} data + * The data attached to the notification. + */ + async observe(subject, topic, data) { + switch (topic) { + case "intl:app-locales-changed": { + await this.l10n.ready; + this.clear(); + break; + } + } + } + + /** + * Cache keys => cached message objects + */ + _messagesByKey = new Map(); + + /** + * Returns a cache key for a string in `_messagesByKey`. + * + * @param {object} options + * Options + * @param {string} options.id + * The string's Fluent ID. + * @param {object} options.args + * The Fluent arguments as passed to `l10n.setAttributes`. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true to exclude the arguments from the key and include only the ID. + * @returns {string} + * The cache key. + */ + _key({ id, args, excludeArgsFromCacheKey }) { + // Keys are `id` plus JSON'ed `args` values. `JSON.stringify` doesn't + // guarantee a particular ordering of object properties, so instead of + // stringifying `args` as is, sort its entries by key and then pull out the + // values. The final key is a JSON'ed array of `id` concatenated with the + // sorted-by-key `args` values. + args = (!excludeArgsFromCacheKey && args) || []; + let argValues = Object.entries(args) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([_, value]) => value); + let parts = [id].concat(argValues); + return JSON.stringify(parts); + } +} + +/** + * This class provides a way of serializing access to a resource. It's a queue + * of callbacks (or "tasks") where each callback is called and awaited in order, + * one at a time. + */ +export class TaskQueue { + /** + * @returns {Promise} + * Resolves when the queue becomes empty. If the queue is already empty, + * then a resolved promise is returned. + */ + get emptyPromise() { + if (!this._queue.length) { + return Promise.resolve(); + } + return new Promise(resolve => this._emptyCallbacks.push(resolve)); + } + + /** + * Adds a callback function to the task queue. The callback will be called + * after all other callbacks before it in the queue. This method returns a + * promise that will be resolved after awaiting the callback. The promise will + * be resolved with the value returned by the callback. + * + * @param {Function} callback + * The function to queue. + * @returns {Promise} + * Resolved after the task queue calls and awaits `callback`. It will be + * resolved with the value returned by `callback`. If `callback` throws an + * error, then it will be rejected with the error. + */ + queue(callback) { + return new Promise((resolve, reject) => { + this._queue.push({ callback, resolve, reject }); + if (this._queue.length == 1) { + this._doNextTask(); + } + }); + } + + /** + * Calls the next function in the task queue and recurses until the queue is + * empty. Once empty, all empty callback functions are called. + */ + async _doNextTask() { + if (!this._queue.length) { + while (this._emptyCallbacks.length) { + let callback = this._emptyCallbacks.shift(); + callback(); + } + return; + } + + let { callback, resolve, reject } = this._queue[0]; + try { + let value = await callback(); + resolve(value); + } catch (error) { + console.error(error); + reject(error); + } + this._queue.shift(); + this._doNextTask(); + } + + _queue = []; + _emptyCallbacks = []; +} diff --git a/browser/components/urlbar/UrlbarValueFormatter.sys.mjs b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs new file mode 100644 index 0000000000..84099c96e5 --- /dev/null +++ b/browser/components/urlbar/UrlbarValueFormatter.sys.mjs @@ -0,0 +1,512 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +ChromeUtils.defineModuleGetter( + lazy, + "BrowserUIUtils", + "resource:///modules/BrowserUIUtils.jsm" +); + +/** + * Applies URL highlighting and other styling to the text in the urlbar input, + * depending on the text. + */ +export class UrlbarValueFormatter { + /** + * @param {UrlbarInput} urlbarInput + * The parent instance of UrlbarInput + */ + constructor(urlbarInput) { + this.urlbarInput = urlbarInput; + this.window = this.urlbarInput.window; + this.document = this.window.document; + + // This is used only as an optimization to avoid removing formatting in + // the _remove* format methods when no formatting is actually applied. + this._formattingApplied = false; + + this.window.addEventListener("resize", this); + } + + get inputField() { + return this.urlbarInput.inputField; + } + + get scheme() { + return this.urlbarInput.querySelector("#urlbar-scheme"); + } + + async update() { + let instance = (this._updateInstance = {}); + + // _getUrlMetaData does URI fixup, which depends on the search service, so + // make sure it's initialized, or URIFixup may force synchronous + // initialization. It can be uninitialized here on session restore. Skip + // this if the service is already initialized in order to avoid the async + // call in the common case. However, we can't access Service.search before + // first paint (delayed startup) because there's a performance test that + // prohibits it, so first await delayed startup. + if (!this.window.gBrowserInit.delayedStartupFinished) { + await this.window.delayedStartupPromise; + if (this._updateInstance != instance) { + return; + } + } + if (!Services.search.isInitialized) { + try { + await Services.search.init(); + } catch {} + + if (this._updateInstance != instance) { + return; + } + } + + // If this window is being torn down, stop here + if (!this.window.docShell) { + return; + } + + // Cleanup that must be done in any case, even if there's no value. + this.urlbarInput.removeAttribute("domaindir"); + this.scheme.value = ""; + + if (!this.inputField.value) { + return; + } + + // Remove the current formatting. + this._removeURLFormat(); + this._removeSearchAliasFormat(); + + // Apply new formatting. Formatter methods should return true if they + // successfully formatted the value and false if not. We apply only + // one formatter at a time, so we stop at the first successful one. + this._formattingApplied = this._formatURL() || this._formatSearchAlias(); + } + + _ensureFormattedHostVisible(urlMetaData) { + // Used to avoid re-entrance in the requestAnimationFrame callback. + let instance = (this._formatURLInstance = {}); + + // Make sure the host is always visible. Since it is aligned on + // the first strong directional character, we set scrollLeft + // appropriately to ensure the domain stays visible in case of an + // overflow. + this.window.requestAnimationFrame(() => { + // Check for re-entrance. On focus change this formatting code is + // invoked regardless, thus this should be enough. + if (this._formatURLInstance != instance) { + return; + } + + // In the future, for example in bug 525831, we may add a forceRTL + // char just after the domain, and in such a case we should not + // scroll to the left. + urlMetaData = urlMetaData || this._getUrlMetaData(); + if (!urlMetaData) { + this.urlbarInput.removeAttribute("domaindir"); + return; + } + let { url, preDomain, domain } = urlMetaData; + let directionality = this.window.windowUtils.getDirectionFromText(domain); + if ( + directionality == this.window.windowUtils.DIRECTION_RTL && + url[preDomain.length + domain.length] != "\u200E" + ) { + this.urlbarInput.setAttribute("domaindir", "rtl"); + this.inputField.scrollLeft = this.inputField.scrollLeftMax; + } else { + this.urlbarInput.setAttribute("domaindir", "ltr"); + this.inputField.scrollLeft = 0; + } + this.urlbarInput.updateTextOverflow(); + }); + } + + _getUrlMetaData() { + if (this.urlbarInput.focused) { + return null; + } + + let inputValue = this.inputField.value; + // getFixupURIInfo logs an error if the URL is empty. Avoid that by + // returning early. + if (!inputValue) { + return null; + } + let browser = this.window.gBrowser.selectedBrowser; + + // Since doing a full URIFixup and offset calculations is expensive, we + // keep the metadata cached in the browser itself, so when switching tabs + // we can skip most of this. + if (browser._urlMetaData && browser._urlMetaData.inputValue == inputValue) { + return browser._urlMetaData.data; + } + browser._urlMetaData = { inputValue, data: null }; + + // Get the URL from the fixup service: + let flags = + Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP; + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { + flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + + let uriInfo; + try { + uriInfo = Services.uriFixup.getFixupURIInfo( + this.urlbarInput.untrimmedValue, + flags + ); + } catch (ex) {} + // Ignore if we couldn't make a URI out of this, the URI resulted in a search, + // or the URI has a non-http(s)/ftp protocol. + if ( + !uriInfo || + !uriInfo.fixedURI || + uriInfo.keywordProviderName || + !["http", "https", "ftp"].includes(uriInfo.fixedURI.scheme) + ) { + return null; + } + + // If we trimmed off the http scheme, ensure we stick it back on before + // trying to figure out what domain we're accessing, so we don't get + // confused by user:pass@host http URLs. We later use + // trimmedLength to ensure we don't count the length of a trimmed protocol + // when determining which parts of the URL to highlight as "preDomain". + let url = inputValue; + let trimmedLength = 0; + let trimmedProtocol = lazy.BrowserUIUtils.trimURLProtocol; + if ( + uriInfo.fixedURI.spec.startsWith(trimmedProtocol) && + !inputValue.startsWith(trimmedProtocol) + ) { + url = trimmedProtocol + inputValue; + trimmedLength = trimmedProtocol.length; + } + + // This RegExp is not a perfect match, and for specially crafted URLs it may + // get the host wrong; for safety reasons we will later compare the found + // host with the one that will actually be loaded. + let matchedURL = url.match( + /^(([a-z]+:\/\/)(?:[^\/#?]+@)?)(\S+?)(?::\d+)?\s*(?:[\/#?]|$)/ + ); + if (!matchedURL) { + return null; + } + let [, preDomain, schemeWSlashes, domain] = matchedURL; + + // If the found host differs from the fixed URI one, we can't properly + // highlight it. To stay on the safe side, we clobber user's input with + // the fixed URI and apply highlight to that one instead. + let replaceUrl = false; + try { + replaceUrl = + Services.io.newURI("http://" + domain).displayHost != + uriInfo.fixedURI.displayHost; + } catch (ex) { + return null; + } + if (replaceUrl) { + if (this._inGetUrlMetaData) { + // Protect from infinite recursion. + return null; + } + try { + this._inGetUrlMetaData = true; + this.window.gBrowser.userTypedValue = null; + this.urlbarInput.setURI(uriInfo.fixedURI); + return this._getUrlMetaData(); + } finally { + this._inGetUrlMetaData = false; + } + } + + return (browser._urlMetaData.data = { + domain, + origin: uriInfo.fixedURI.host, + preDomain, + schemeWSlashes, + trimmedLength, + url, + }); + } + + _removeURLFormat() { + if (!this._formattingApplied) { + return; + } + let controller = this.urlbarInput.editor.selectionController; + let strikeOut = controller.getSelection(controller.SELECTION_URLSTRIKEOUT); + strikeOut.removeAllRanges(); + let selection = controller.getSelection(controller.SELECTION_URLSECONDARY); + selection.removeAllRanges(); + this._formatScheme(controller.SELECTION_URLSTRIKEOUT, true); + this._formatScheme(controller.SELECTION_URLSECONDARY, true); + this.inputField.style.setProperty("--urlbar-scheme-size", "0px"); + } + + /** + * If the input value is a URL and the input is not focused, this + * formatter method highlights the domain, and if mixed content is present, + * it crosses out the https scheme. It also ensures that the host is + * visible (not scrolled out of sight). + * + * @returns {boolean} + * True if formatting was applied and false if not. + */ + _formatURL() { + let urlMetaData = this._getUrlMetaData(); + if (!urlMetaData) { + return false; + } + + let { domain, origin, preDomain, schemeWSlashes, trimmedLength, url } = + urlMetaData; + // We strip http, so we should not show the scheme box for it. + if (!lazy.UrlbarPrefs.get("trimURLs") || schemeWSlashes != "http://") { + this.scheme.value = schemeWSlashes; + this.inputField.style.setProperty( + "--urlbar-scheme-size", + schemeWSlashes.length + "ch" + ); + } + + this._ensureFormattedHostVisible(urlMetaData); + + if (!lazy.UrlbarPrefs.get("formatting.enabled")) { + return false; + } + + let editor = this.urlbarInput.editor; + let controller = editor.selectionController; + + this._formatScheme(controller.SELECTION_URLSECONDARY); + + let textNode = editor.rootElement.firstChild; + + // Strike out the "https" part if mixed active content is loaded. + if ( + this.urlbarInput.getAttribute("pageproxystate") == "valid" && + url.startsWith("https:") && + this.window.gBrowser.securityUI.state & + Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT + ) { + let range = this.document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 5); + let strikeOut = controller.getSelection( + controller.SELECTION_URLSTRIKEOUT + ); + strikeOut.addRange(range); + this._formatScheme(controller.SELECTION_URLSTRIKEOUT); + } + + let baseDomain = domain; + let subDomain = ""; + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(origin); + if (!domain.endsWith(baseDomain)) { + // getBaseDomainFromHost converts its resultant to ACE. + let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + baseDomain = IDNService.convertACEtoUTF8(baseDomain); + } + } catch (e) {} + if (baseDomain != domain) { + subDomain = domain.slice(0, -baseDomain.length); + } + + let selection = controller.getSelection(controller.SELECTION_URLSECONDARY); + + let rangeLength = preDomain.length + subDomain.length - trimmedLength; + if (rangeLength) { + let range = this.document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, rangeLength); + selection.addRange(range); + } + + let startRest = preDomain.length + domain.length - trimmedLength; + if (startRest < url.length - trimmedLength) { + let range = this.document.createRange(); + range.setStart(textNode, startRest); + range.setEnd(textNode, url.length - trimmedLength); + selection.addRange(range); + } + + return true; + } + + _formatScheme(selectionType, clear) { + let editor = this.scheme.editor; + let controller = editor.selectionController; + let textNode = editor.rootElement.firstChild; + let selection = controller.getSelection(selectionType); + if (clear) { + selection.removeAllRanges(); + } else { + let r = this.document.createRange(); + r.setStart(textNode, 0); + r.setEnd(textNode, textNode.textContent.length); + selection.addRange(r); + } + } + + _removeSearchAliasFormat() { + if (!this._formattingApplied) { + return; + } + let selection = this.urlbarInput.editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + selection.removeAllRanges(); + } + + /** + * If the input value starts with an @engine search alias, this highlights it. + * + * @returns {boolean} + * True if formatting was applied and false if not. + */ + _formatSearchAlias() { + if (!lazy.UrlbarPrefs.get("formatting.enabled")) { + return false; + } + + let editor = this.urlbarInput.editor; + let textNode = editor.rootElement.firstChild; + let value = textNode.textContent; + let trimmedValue = value.trim(); + + if ( + !trimmedValue.startsWith("@") || + (this.urlbarInput.popup || this.urlbarInput.view).oneOffSearchButtons + .selectedButton + ) { + return false; + } + + let alias = this._getSearchAlias(); + if (!alias) { + return false; + } + + // Make sure the current input starts with the alias because it can change + // without the popup results changing. Most notably that happens when the + // user performs a search using an alias: The popup closes (preserving its + // results), the search results page loads, and the input value is set to + // the URL of the page. + if (trimmedValue != alias && !trimmedValue.startsWith(alias + " ")) { + return false; + } + + let index = value.indexOf(alias); + if (index < 0) { + return false; + } + + // We abuse the SELECTION_FIND selection type to do our highlighting. + // It's the only type that works with Selection.setColors(). + let selection = editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + + let range = this.document.createRange(); + range.setStart(textNode, index); + range.setEnd(textNode, index + alias.length); + selection.addRange(range); + + let fg = "#2362d7"; + let bg = "#d2e6fd"; + + // Selection.setColors() will swap the given foreground and background + // colors if it detects that the contrast between the background + // color and the frame color is too low. Normally we don't want that + // to happen; we want it to use our colors as given (even if setColors + // thinks the contrast is too low). But it's a nice feature for non- + // default themes, where the contrast between our background color and + // the input's frame color might actually be too low. We can + // (hackily) force setColors to use our colors as given by passing + // them as the alternate colors. Otherwise, allow setColors to swap + // them, which we can do by passing "currentColor". See + // nsTextPaintStyle::GetHighlightColors for details. + if ( + this.document.documentElement.hasAttribute("lwtheme") || + this.window.matchMedia("(prefers-contrast)").matches + ) { + // non-default theme(s) + selection.setColors(fg, bg, "currentColor", "currentColor"); + } else { + // default themes + selection.setColors(fg, bg, fg, bg); + } + + return true; + } + + _getSearchAlias() { + // To determine whether the input contains a valid alias, check if the + // selected result is a search result with an alias. If there is no selected + // result, we check the first result in the view, for cases when we do not + // highlight token alias results. The selected result is null when the popup + // is closed, but we want to continue highlighting the alias when the popup + // is closed, and that's why we keep around the previously selected result + // in _selectedResult. + this._selectedResult = + this.urlbarInput.view.selectedResult || + this.urlbarInput.view.getResultAtIndex(0) || + this._selectedResult; + + if ( + this._selectedResult && + this._selectedResult.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH + ) { + return this._selectedResult.payload.keyword || null; + } + return null; + } + + /** + * Passes DOM events to the _on_<event type> methods. + * + * @param {Event} event + * DOM event. + */ + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarValueFormatter event: " + event.type); + } + } + + _on_resize(event) { + if (event.target != this.window) { + return; + } + // Make sure the host remains visible in the input field when the window is + // resized. We don't want to hurt resize performance though, so do this + // only after resize events have stopped and a small timeout has elapsed. + if (this._resizeThrottleTimeout) { + this.window.clearTimeout(this._resizeThrottleTimeout); + } + this._resizeThrottleTimeout = this.window.setTimeout(() => { + this._resizeThrottleTimeout = null; + this._ensureFormattedHostVisible(); + }, 100); + } +} diff --git a/browser/components/urlbar/UrlbarView.sys.mjs b/browser/components/urlbar/UrlbarView.sys.mjs new file mode 100644 index 0000000000..fe6b0e6ce0 --- /dev/null +++ b/browser/components/urlbar/UrlbarView.sys.mjs @@ -0,0 +1,3355 @@ +/* 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 { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + L10nCache: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchOneOffs: "resource:///modules/UrlbarSearchOneOffs.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +// Query selector for selectable elements in results. +const SELECTABLE_ELEMENT_SELECTOR = "[role=button], [selectable]"; +const KEYBOARD_SELECTABLE_ELEMENT_SELECTOR = + "[role=button]:not([keyboard-inaccessible]), [selectable]"; + +const ZERO_PREFIX_HISTOGRAM_DWELL_TIME = "FX_URLBAR_ZERO_PREFIX_DWELL_TIME_MS"; +const ZERO_PREFIX_SCALAR_ABANDONMENT = "urlbar.zeroprefix.abandonment"; +const ZERO_PREFIX_SCALAR_ENGAGEMENT = "urlbar.zeroprefix.engagement"; +const ZERO_PREFIX_SCALAR_EXPOSURE = "urlbar.zeroprefix.exposure"; + +const RESULT_MENU_COMMANDS = { + DISMISS: "dismiss", + HELP: "help", +}; + +const getBoundsWithoutFlushing = element => + element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element); + +// Used to get a unique id to use for row elements, it wraps at 9999, that +// should be plenty for our needs. +let gUniqueIdSerial = 1; +function getUniqueId(prefix) { + return prefix + (gUniqueIdSerial++ % 9999); +} + +/** + * Receives and displays address bar autocomplete results. + */ +export class UrlbarView { + // Stale rows are removed on a timer with this timeout. + static removeStaleRowsTimeout = 400; + + /** + * @param {UrlbarInput} input + * The UrlbarInput instance belonging to this UrlbarView instance. + */ + constructor(input) { + this.input = input; + this.panel = input.panel; + this.controller = input.controller; + this.document = this.panel.ownerDocument; + this.window = this.document.defaultView; + + this.#mainContainer = this.panel.querySelector(".urlbarView-body-inner"); + this.#rows = this.panel.querySelector(".urlbarView-results"); + this.resultMenu = this.panel.querySelector(".urlbarView-result-menu"); + this.#resultMenuCommands = new WeakMap(); + + this.#rows.addEventListener("mousedown", this); + + // For the horizontal fade-out effect, set the overflow attribute on result + // rows when they overflow. + this.#rows.addEventListener("overflow", this); + this.#rows.addEventListener("underflow", this); + + this.resultMenu.addEventListener("command", this); + this.resultMenu.addEventListener("popupshowing", this); + + // `noresults` is used to style the one-offs without their usual top border + // when no results are present. + this.panel.setAttribute("noresults", "true"); + + this.controller.setView(this); + this.controller.addQueryListener(this); + // This is used by autoOpen to avoid flickering results when reopening + // previously abandoned searches. + this.queryContextCache = new QueryContextCache(5); + + // We cache l10n strings to avoid Fluent's async lookup. + this.#l10nCache = new lazy.L10nCache(this.document.l10n); + + for (let viewTemplate of UrlbarView.dynamicViewTemplatesByName.values()) { + if (viewTemplate.stylesheet) { + addDynamicStylesheet(this.window, viewTemplate.stylesheet); + } + } + } + + get oneOffSearchButtons() { + if (!this.#oneOffSearchButtons) { + this.#oneOffSearchButtons = new lazy.UrlbarSearchOneOffs(this); + this.#oneOffSearchButtons.addEventListener( + "SelectedOneOffButtonChanged", + this + ); + } + return this.#oneOffSearchButtons; + } + + /** + * Whether the panel is open. + * + * @returns {boolean} + */ + get isOpen() { + return this.input.hasAttribute("open"); + } + + get allowEmptySelection() { + let { heuristicResult } = this.#queryContext || {}; + return !heuristicResult || !this.#shouldShowHeuristic(heuristicResult); + } + + get selectedRowIndex() { + if (!this.isOpen) { + return -1; + } + + let selectedRow = this.#getSelectedRow(); + + if (!selectedRow) { + return -1; + } + + return selectedRow.result.rowIndex; + } + + set selectedRowIndex(val) { + if (!this.isOpen) { + throw new Error( + "UrlbarView: Cannot select an item if the view isn't open." + ); + } + + if (val < 0) { + this.#selectElement(null); + return; + } + + let items = Array.from(this.#rows.children).filter(r => + this.#isElementVisible(r) + ); + if (val >= items.length) { + throw new Error(`UrlbarView: Index ${val} is out of bounds.`); + } + + // Select the first selectable element inside the row. If it doesn't + // contain a selectable element, clear the selection. + let row = items[val]; + let element = this.#getNextSelectableElement(row); + if (this.#getRowFromElement(element) != row) { + element = null; + } + + this.#selectElement(element); + } + + get selectedElementIndex() { + if (!this.isOpen || !this.#selectedElement) { + return -1; + } + + return this.#selectedElement.elementIndex; + } + + /** + * @returns {UrlbarResult} + * The currently selected result. + */ + get selectedResult() { + if (!this.isOpen) { + return null; + } + + return this.#getSelectedRow()?.result; + } + + /** + * @returns {Element} + * The currently selected element. + */ + get selectedElement() { + if (!this.isOpen) { + return null; + } + + return this.#selectedElement; + } + + /** + * @returns {boolean} + * Whether the SPACE key should activate the selected element (if any) + * instead of adding to the input value. + */ + shouldSpaceActivateSelectedElement() { + // We want SPACE to activate buttons only. + if (this.selectedElement?.getAttribute("role") != "button") { + return false; + } + // Make sure the input field is empty, otherwise the user might want to add + // a space to the current search string. As it stands, selecting a button + // should always clear the input field, so this is just an extra safeguard. + if (this.input.value) { + return false; + } + return true; + } + + /** + * Clears selection, regardless of view status. + */ + clearSelection() { + this.#selectElement(null, { updateInput: false }); + } + + /** + * @returns {number} + * The number of visible results in the view. Note that this may be larger + * than the number of results in the current query context since the view + * may be showing stale results. + */ + get visibleRowCount() { + let sum = 0; + for (let row of this.#rows.children) { + sum += Number(this.#isElementVisible(row)); + } + return sum; + } + + /** + * Returns the result of the row containing the given element, or the result + * of the element if it itself is a row. + * + * @param {Element} element + * An element in the view. + * @returns {UrlbarResult} + * The result of the element's row. + */ + getResultFromElement(element) { + return element?.classList.contains("urlbarView-result-menuitem") + ? this.#resultMenuResult + : this.#getRowFromElement(element)?.result; + } + + /** + * @param {number} index + * The index from which to fetch the result. + * @returns {UrlbarResult} + * The result at `index`. Null if the view is closed or if there are no + * results. + */ + getResultAtIndex(index) { + if ( + !this.isOpen || + !this.#rows.children.length || + index >= this.#rows.children.length + ) { + return null; + } + + return this.#rows.children[index].result; + } + + /** + * @param {UrlbarResult} result A result. + * @returns {boolean} True if the given result is selected. + */ + resultIsSelected(result) { + if (this.selectedRowIndex < 0) { + return false; + } + + return result.rowIndex == this.selectedRowIndex; + } + + /** + * Moves the view selection forward or backward. + * + * @param {number} amount + * The number of steps to move. + * @param {object} options Options object + * @param {boolean} [options.reverse] + * Set to true to select the previous item. By default the next item + * will be selected. + * @param {boolean} [options.userPressedTab] + * Set to true if the user pressed Tab to select a result. Default false. + */ + selectBy(amount, { reverse = false, userPressedTab = false } = {}) { + if (!this.isOpen) { + throw new Error( + "UrlbarView: Cannot select an item if the view isn't open." + ); + } + + // Freeze results as the user is interacting with them, unless we are + // deferring events while waiting for critical results. + if (!this.input.eventBufferer.isDeferringEvents) { + this.controller.cancelQuery(); + } + + if (!userPressedTab) { + let { selectedRowIndex } = this; + let end = this.visibleRowCount - 1; + if (selectedRowIndex == -1) { + this.selectedRowIndex = reverse ? end : 0; + return; + } + let endReached = selectedRowIndex == (reverse ? 0 : end); + if (endReached) { + if (this.allowEmptySelection) { + this.#selectElement(null); + } else { + this.selectedRowIndex = reverse ? end : 0; + } + return; + } + this.selectedRowIndex = Math.max( + 0, + Math.min(end, selectedRowIndex + amount * (reverse ? -1 : 1)) + ); + return; + } + + // Tab key handling below. + + // Do not set aria-activedescendant if the user is moving to a + // tab-to-search result with the Tab key. If + // accessibility.tabToSearch.announceResults is set, the tab-to-search + // result was announced to the user as they typed. We don't set + // aria-activedescendant so the user doesn't think they have to press + // Enter to enter search mode. See bug 1647929. + const isSkippableTabToSearchAnnounce = selectedElt => { + let result = this.getResultFromElement(selectedElt); + let skipAnnouncement = + result?.providerName == "TabToSearch" && + !this.#announceTabToSearchOnSelection && + lazy.UrlbarPrefs.get("accessibility.tabToSearch.announceResults"); + if (skipAnnouncement) { + // Once we skip setting aria-activedescendant once, we should not skip + // it again if the user returns to that result. + this.#announceTabToSearchOnSelection = true; + } + return skipAnnouncement; + }; + + let selectedElement = this.#selectedElement; + + // We cache the first and last rows since they will not change while + // selectBy is running. + let firstSelectableElement = this.#getFirstSelectableElement(); + // #getLastSelectableElement will not return an element that is over + // maxResults and thus may be hidden and not selectable. + let lastSelectableElement = this.#getLastSelectableElement(); + + if (!selectedElement) { + selectedElement = reverse + ? lastSelectableElement + : firstSelectableElement; + this.#selectElement(selectedElement, { + setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement), + }); + return; + } + let endReached = reverse + ? selectedElement == firstSelectableElement + : selectedElement == lastSelectableElement; + if (endReached) { + if (this.allowEmptySelection) { + selectedElement = null; + } else { + selectedElement = reverse + ? lastSelectableElement + : firstSelectableElement; + } + this.#selectElement(selectedElement, { + setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement), + }); + return; + } + + while (amount-- > 0) { + let next = reverse + ? this.#getPreviousSelectableElement(selectedElement) + : this.#getNextSelectableElement(selectedElement); + if (!next) { + break; + } + selectedElement = next; + } + this.#selectElement(selectedElement, { + setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement), + }); + } + + async acknowledgeFeedback(result) { + let row = this.#rows.children[result.rowIndex]; + if (!row) { + return; + } + + let l10n = { id: "firefox-suggest-feedback-acknowledgment" }; + await this.#l10nCache.ensure(l10n); + if (row.result != result) { + return; + } + + let { value } = this.#l10nCache.get(l10n); + row.setAttribute("feedback-acknowledgment", value); + this.window.A11yUtils.announce({ + raw: value, + source: row._content.closest("[role=option]"), + }); + } + + acknowledgeDismissal(result) { + let row = this.#rows.children[result.rowIndex]; + if (!row || row.result != result) { + return; + } + + // The row is no longer selectable. It's necessary to clear the selection + // before replacing the row because replacement will likely create a new + // `urlbarView-row-inner`, which will interfere with the ability of + // `#selectElement()` to clear the old selection after replacement, below. + let isSelected = this.#getSelectedRow() == row; + if (isSelected) { + this.#selectElement(null, { updateInput: false }); + } + this.#setRowSelectable(row, false); + + // Replace the row with a dismissal acknowledgment tip. + let tip = new lazy.UrlbarResult( + lazy.UrlbarUtils.RESULT_TYPE.TIP, + lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: "dismissalAcknowledgment", + titleL10n: { id: "firefox-suggest-dismissal-acknowledgment" }, + buttons: [{ l10n: { id: "urlbar-search-tips-confirm-short" } }], + icon: "chrome://branding/content/icon32.png", + } + ); + this.#updateRow(row, tip); + this.#updateIndices(); + + // If the row was selected, move the selection to the tip button. + if (isSelected) { + this.#selectElement(this.#getNextSelectableElement(row), { + updateInput: false, + }); + } + } + + removeAccessibleFocus() { + this.#setAccessibleFocus(null); + } + + clear() { + this.#rows.textContent = ""; + this.panel.setAttribute("noresults", "true"); + this.clearSelection(); + this.visibleResults = []; + } + + /** + * Closes the view, cancelling the query if necessary. + * + * @param {object} options Options object + * @param {boolean} [options.elementPicked] + * True if the view is being closed because a result was picked. + * @param {boolean} [options.showFocusBorder] + * True if the Urlbar focus border should be shown after the view is closed. + */ + close({ elementPicked = false, showFocusBorder = true } = {}) { + this.controller.cancelQuery(); + // We do not show the focus border when an element is picked because we'd + // flash it just before the input is blurred. The focus border is removed + // in UrlbarInput._on_blur. + if (!elementPicked && showFocusBorder) { + this.input.removeAttribute("suppress-focus-border"); + } + + if (!this.isOpen) { + return; + } + + this.#inputWidthOnLastClose = getBoundsWithoutFlushing( + this.input.textbox + ).width; + + // We exit search mode preview on close since the result previewing it is + // implicitly unselected. + if (this.input.searchMode?.isPreview) { + this.input.searchMode = null; + } + + this.resultMenu.hidePopup(); + this.removeAccessibleFocus(); + this.input.inputField.setAttribute("aria-expanded", "false"); + this.#openPanelInstance = null; + this.#previousTabToSearchEngine = null; + + this.input.removeAttribute("open"); + this.input.endLayoutExtend(); + + // Search Tips can open the view without the Urlbar being focused. If the + // tip is ignored (e.g. the page content is clicked or the window loses + // focus) we should discard the telemetry event created when the view was + // opened. + if (!this.input.focused && !elementPicked) { + this.controller.engagementEvent.discard(); + this.controller.engagementEvent.record(null, {}); + } + + this.window.removeEventListener("resize", this); + this.window.removeEventListener("blur", this); + + this.controller.notify(this.controller.NOTIFICATIONS.VIEW_CLOSE); + + if (this.#isShowingZeroPrefix) { + if (elementPicked) { + Services.telemetry.scalarAdd(ZERO_PREFIX_SCALAR_ENGAGEMENT, 1); + } else { + Services.telemetry.scalarAdd(ZERO_PREFIX_SCALAR_ABANDONMENT, 1); + } + this.#setIsShowingZeroPrefix(false); + } + } + + /** + * This can be used to open the view automatically as a consequence of + * specific user actions. For Top Sites searches (without a search string) + * the view is opened only for mouse or keyboard interactions. + * If the user abandoned a search (there is a search string) the view is + * reopened, and we try to use cached results to reduce flickering, then a new + * query is started to refresh results. + * + * @param {object} options Options object + * @param {Event} options.event The event associated with the call to autoOpen. + * @param {boolean} [options.suppressFocusBorder] If true, we hide the focus border + * when the panel is opened. This is true by default to avoid flashing + * the border when the unfocused address bar is clicked. + * @returns {boolean} Whether the view was opened. + */ + autoOpen({ event, suppressFocusBorder = true }) { + if (this.#pickSearchTipIfPresent(event)) { + return false; + } + + if (!event) { + return false; + } + + let queryOptions = { event }; + + if ( + !this.input.value || + (this.input.getAttribute("pageproxystate") == "valid" && + !this.window.gBrowser.selectedBrowser.searchTerms) + ) { + if (!this.isOpen && ["mousedown", "command"].includes(event.type)) { + // Try to reuse the cached top-sites context. If it's not cached, then + // there will be a gap of time between when the input is focused and + // when the view opens that can be perceived as flicker. + if (!this.input.searchMode && this.queryContextCache.topSitesContext) { + this.onQueryResults(this.queryContextCache.topSitesContext); + } + this.input.startQuery(queryOptions); + if (suppressFocusBorder) { + this.input.toggleAttribute("suppress-focus-border", true); + } + return true; + } + return false; + } + + // Reopen abandoned searches only if the input is focused. + if (!this.input.focused) { + return false; + } + + // Tab switch is the only case where we requery if the view is open, because + // switching tabs doesn't necessarily close the view. + if (this.isOpen && event.type != "tabswitch") { + return false; + } + + // We can reuse the current rows as they are if the input value and width + // haven't changed since the view was closed. The width check is related to + // row overflow: If we reuse the current rows, overflow and underflow events + // won't fire even if the view's width has changed and there are rows that + // do actually overflow or underflow. That means previously overflowed rows + // may unnecessarily show the overflow gradient, for example. + if ( + this.#rows.firstElementChild && + this.#queryContext.searchString == this.input.value && + this.#inputWidthOnLastClose == + getBoundsWithoutFlushing(this.input.textbox).width + ) { + // We can reuse the current rows. + queryOptions.allowAutofill = this.#queryContext.allowAutofill; + } else { + // To reduce flickering, try to reuse a cached UrlbarQueryContext. The + // overflow problem is addressed in this case because `onQueryResults()` + // starts the regular view-update process, during which the overflow state + // is reset on all rows. + let cachedQueryContext = this.queryContextCache.get(this.input.value); + if (cachedQueryContext) { + this.onQueryResults(cachedQueryContext); + } + } + + this.controller.engagementEvent.discard(); + queryOptions.searchString = this.input.value; + queryOptions.autofillIgnoresSelection = true; + queryOptions.event.interactionType = "returned"; + + // A search tip can be cached in results if it was shown but ignored + // by the user. Don't open the panel if a search tip is present or it + // will cause a flicker since it'll be quickly overwritten (Bug 1812261). + if ( + this.#queryContext?.results?.length && + this.#queryContext.results[0].type != lazy.UrlbarUtils.RESULT_TYPE.TIP + ) { + this.#openPanel(); + } + + // If we had cached results, this will just refresh them, avoiding results + // flicker, otherwise there may be some noise. + this.input.startQuery(queryOptions); + if (suppressFocusBorder) { + this.input.toggleAttribute("suppress-focus-border", true); + } + return true; + } + + // UrlbarController listener methods. + onQueryStarted(queryContext) { + this.#queryWasCancelled = false; + this.#queryUpdatedResults = false; + this.#openPanelInstance = null; + if (!queryContext.searchString) { + this.#previousTabToSearchEngine = null; + } + this.#startRemoveStaleRowsTimer(); + + // Cache l10n strings so they're available when we update the view as + // results arrive. This is a no-op for strings that are already cached. + // `#cacheL10nStrings` is async but we don't await it because doing so would + // require view updates to be async. Instead we just opportunistically cache + // and if there's a cache miss we fall back to `l10n.setAttributes`. + this.#cacheL10nStrings(); + } + + onQueryCancelled(queryContext) { + this.#queryWasCancelled = true; + this.#cancelRemoveStaleRowsTimer(); + } + + onQueryFinished(queryContext) { + this.#cancelRemoveStaleRowsTimer(); + if (this.#queryWasCancelled) { + return; + } + + // At this point the query finished successfully. If it returned some + // results, remove stale rows. Otherwise remove all rows. + if (this.#queryUpdatedResults) { + this.#removeStaleRows(); + } else { + this.clear(); + } + + // Now that the view has finished updating for this query, call + // `#setIsShowingZeroPrefix()`. + this.#setIsShowingZeroPrefix(!queryContext.searchString); + + // If the query returned results, we're done. + if (this.#queryUpdatedResults) { + return; + } + + // If search mode isn't active, close the view. + if (!this.input.searchMode) { + this.close(); + return; + } + + // Search mode is active. If the one-offs should be shown, make sure they + // are enabled and show the view. + let openPanelInstance = (this.#openPanelInstance = {}); + this.oneOffSearchButtons.willHide().then(willHide => { + if (!willHide && openPanelInstance == this.#openPanelInstance) { + this.oneOffSearchButtons.enable(true); + this.#openPanel(); + } + }); + } + + onQueryResults(queryContext) { + this.queryContextCache.put(queryContext); + this.#queryContext = queryContext; + + if (!this.isOpen) { + this.clear(); + } + this.#queryUpdatedResults = true; + this.#updateResults(); + + let firstResult = queryContext.results[0]; + + if (queryContext.lastResultCount == 0) { + // Clear the selection when we get a new set of results. + this.#selectElement(null, { + updateInput: false, + }); + + // Show the one-off search buttons unless any of the following are true: + // * The first result is a search tip + // * The search string is empty + // * The search string starts with an `@` or a search restriction + // character + this.oneOffSearchButtons.enable( + firstResult.providerName != "UrlbarProviderSearchTips" && + queryContext.trimmedSearchString[0] != "@" && + (queryContext.trimmedSearchString[0] != + lazy.UrlbarTokenizer.RESTRICT.SEARCH || + queryContext.trimmedSearchString.length != 1) + ); + } + + if (!this.selectedElement && !this.oneOffSearchButtons.selectedButton) { + if (firstResult.heuristic) { + // Select the heuristic result. The heuristic may not be the first + // result added, which is why we do this check here when each result is + // added and not above. + if (this.#shouldShowHeuristic(firstResult)) { + this.#selectElement(this.#getFirstSelectableElement(), { + updateInput: false, + setAccessibleFocus: + this.controller._userSelectionBehavior == "arrow", + }); + } else { + this.input.setResultForCurrentValue(firstResult); + } + } else if ( + firstResult.payload.providesSearchMode && + queryContext.trimmedSearchString != "@" + ) { + // Filtered keyword offer results can be in the first position but not + // be heuristic results. We do this so the user can press Tab to select + // them, resembling tab-to-search. In that case, the input value is + // still associated with the first result. + this.input.setResultForCurrentValue(firstResult); + } + } + + // Announce tab-to-search results to screen readers as the user types. + // Check to make sure we don't announce the same engine multiple times in + // a row. + let secondResult = queryContext.results[1]; + if ( + secondResult?.providerName == "TabToSearch" && + lazy.UrlbarPrefs.get("accessibility.tabToSearch.announceResults") && + this.#previousTabToSearchEngine != secondResult.payload.engine + ) { + let engine = secondResult.payload.engine; + this.window.A11yUtils.announce({ + id: secondResult.payload.isGeneralPurposeEngine + ? "urlbar-result-action-before-tabtosearch-web" + : "urlbar-result-action-before-tabtosearch-other", + args: { engine }, + }); + this.#previousTabToSearchEngine = engine; + // Do not set aria-activedescendant when the user tabs to the result + // because we already announced it. + this.#announceTabToSearchOnSelection = false; + } + + // If we update the selected element, a new unique ID is generated for it. + // We need to ensure that aria-activedescendant reflects this new ID. + if (this.selectedElement && !this.oneOffSearchButtons.selectedButton) { + let aadID = this.input.inputField.getAttribute("aria-activedescendant"); + if (aadID && !this.document.getElementById(aadID)) { + this.#setAccessibleFocus(this.selectedElement); + } + } + + this.#openPanel(); + + if (firstResult.heuristic) { + // The heuristic result may be a search alias result, so apply formatting + // if necessary. Conversely, the heuristic result of the previous query + // may have been an alias, so remove formatting if necessary. + this.input.formatValue(); + } + + if (queryContext.deferUserSelectionProviders.size) { + // DeferUserSelectionProviders block user selection until the result is + // shown, so it's the view's duty to remove them. + // Doing it sooner, like when the results are added by the provider, + // would not suffice because there's still a delay before those results + // reach the view. + queryContext.results.forEach(r => { + queryContext.deferUserSelectionProviders.delete(r.providerName); + }); + } + } + + /** + * Handles removing a result from the view when it is removed from the query, + * and attempts to select the new result on the same row. + * + * This assumes that the result rows are in index order. + * + * @param {number} index The index of the result that has been removed. + */ + onQueryResultRemoved(index) { + let rowToRemove = this.#rows.children[index]; + rowToRemove.remove(); + + this.#updateIndices(); + + if (rowToRemove != this.#getSelectedRow()) { + return; + } + + // Select the row at the same index, if possible. + let newSelectionIndex = index; + if (index >= this.#queryContext.results.length) { + newSelectionIndex = this.#queryContext.results.length - 1; + } + if (newSelectionIndex >= 0) { + this.selectedRowIndex = newSelectionIndex; + } + } + + openResultMenu(result, anchor) { + this.#resultMenuResult = result; + + if (AppConstants.platform == "macosx") { + // `openPopup(anchor)` doesn't use a native context menu, which is very + // noticeable on Mac. Use `openPopup()` with x and y coords instead. See + // bug 1831760 and bug 1710459. + let rect = getBoundsWithoutFlushing(anchor); + rect = this.window.windowUtils.toScreenRectInCSSUnits( + rect.x, + rect.y, + rect.width, + rect.height + ); + this.resultMenu.openPopup(null, { + x: rect.x, + y: rect.y + rect.height, + }); + } else { + this.resultMenu.openPopup(anchor, "bottomright topright"); + } + + anchor.toggleAttribute("open", true); + let listener = event => { + if (event.target == this.resultMenu) { + anchor.removeAttribute("open"); + this.resultMenu.removeEventListener("popuphidden", listener); + } + }; + this.resultMenu.addEventListener("popuphidden", listener); + } + + /** + * Clears the result menu commands cache, removing the cached commands for all + * results. This is useful when the commands for one or more results change + * while the results remain in the view. + */ + invalidateResultMenuCommands() { + this.#resultMenuCommands = new WeakMap(); + } + + /** + * Passes DOM events for the view to the on_<event type> methods. + * + * @param {Event} event + * DOM event from the <view>. + */ + handleEvent(event) { + let methodName = "on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarView event: " + event.type); + } + } + + static dynamicViewTemplatesByName = new Map(); + + /** + * Registers the view template for a dynamic result type. A view template is + * a plain object that describes the DOM subtree for a dynamic result type. + * When a dynamic result is shown in the urlbar view, its type's view template + * is used to construct the part of the view that represents the result. + * + * The specified view template will be available to the urlbars in all current + * and future browser windows until it is unregistered. A given dynamic + * result type has at most one view template. If this method is called for a + * dynamic result type more than once, the view template in the last call + * overrides those in previous calls. + * + * @param {string} name + * The view template will be registered for the dynamic result type with + * this name. + * @param {object} viewTemplate + * This object describes the DOM subtree for the given dynamic result type. + * It should be a tree-like nested structure with each object in the nesting + * representing a DOM element to be created. This tree-like structure is + * achieved using the `children` property described below. Each object in + * the structure may include the following properties: + * + * {string} name + * The name of the object. It is required for all objects in the + * structure except the root object and serves two important functions: + * (1) The element created for the object will automatically have a class + * named `urlbarView-dynamic-${dynamicType}-${name}`, where + * `dynamicType` is the name of the dynamic result type. The element + * will also automatically have an attribute "name" whose value is + * this name. The class and attribute allow the element to be styled + * in CSS. + * (2) The name is used when updating the view. See + * UrlbarProvider.getViewUpdate(). + * Names must be unique within a view template, but they don't need to be + * globally unique. i.e., two different view templates can use the same + * names, and other DOM elements can use the same names in their IDs and + * classes. The name also suffixes the dynamic element's ID: an element + * with name `data` will get the ID `urlbarView-row-{unique number}-data`. + * If there is no name provided for the root element, the root element + * will not get an ID. + * {string} tag + * The tag name of the object. It is required for all objects in the + * structure except the root object and declares the kind of element that + * will be created for the object: span, div, img, etc. + * {object} [attributes] + * An optional mapping from attribute names to values. For each + * name-value pair, an attribute is added to the element created for the + * object. The `id` attribute is reserved and cannot be set by the + * provider. Element IDs are passed back to the provider in getViewUpdate + * if they are needed. + * {array} [children] + * An optional list of children. Each item in the array must be an object + * as described here. For each item, a child element as described by the + * item is created and added to the element created for the parent object. + * {array} [classList] + * An optional list of classes. Each class will be added to the element + * created for the object by calling element.classList.add(). + * {boolean} [overflowable] + * If true, the element's overflow status will be tracked in order to + * fade it out when needed. + * {string} [stylesheet] + * An optional stylesheet URL. This property is valid only on the root + * object in the structure. The stylesheet will be loaded in all browser + * windows so that the dynamic result type view may be styled. + */ + static addDynamicViewTemplate(name, viewTemplate) { + this.dynamicViewTemplatesByName.set(name, viewTemplate); + if (viewTemplate.stylesheet) { + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + addDynamicStylesheet(window, viewTemplate.stylesheet); + } + } + } + + /** + * Unregisters the view template for a dynamic result type. + * + * @param {string} name + * The view template will be unregistered for the dynamic result type with + * this name. + */ + static removeDynamicViewTemplate(name) { + let viewTemplate = this.dynamicViewTemplatesByName.get(name); + if (!viewTemplate) { + return; + } + this.dynamicViewTemplatesByName.delete(name); + if (viewTemplate.stylesheet) { + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + removeDynamicStylesheet(window, viewTemplate.stylesheet); + } + } + } + + // Private properties and methods below. + #announceTabToSearchOnSelection; + #inputWidthOnLastClose = 0; + #l10nCache; + #mainContainer; + #mousedownSelectedElement; + #openPanelInstance; + #oneOffSearchButtons; + #previousTabToSearchEngine; + #queryContext; + #queryUpdatedResults; + #queryWasCancelled; + #removeStaleRowsTimer; + #resultMenuResult; + #resultMenuCommands; + #rows; + #selectedElement; + #zeroPrefixStopwatchInstance = null; + + #createElement(name) { + return this.document.createElementNS("http://www.w3.org/1999/xhtml", name); + } + + #openPanel() { + if (this.isOpen) { + return; + } + this.controller.userSelectionBehavior = "none"; + + this.panel.removeAttribute("actionoverride"); + + this.#enableOrDisableRowWrap(); + + this.input.inputField.setAttribute("aria-expanded", "true"); + + this.input.toggleAttribute("suppress-focus-border", true); + this.input.toggleAttribute("open", true); + this.input.startLayoutExtend(); + + this.window.addEventListener("resize", this); + this.window.addEventListener("blur", this); + + this.controller.notify(this.controller.NOTIFICATIONS.VIEW_OPEN); + } + + #shouldShowHeuristic(result) { + if (!result?.heuristic) { + throw new Error("A heuristic result must be given"); + } + return ( + !lazy.UrlbarPrefs.get("experimental.hideHeuristic") || + result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP + ); + } + + /** + * Whether a result is a search suggestion. + * + * @param {UrlbarResult} result The result to examine. + * @returns {boolean} Whether the result is a search suggestion. + */ + #resultIsSearchSuggestion(result) { + return Boolean( + result && + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.suggestion + ); + } + + /** + * Checks whether the given row index can be update to the result we want + * to apply. This is used in #updateResults to avoid flickering of results, by + * reusing existing rows. + * + * @param {number} rowIndex Index of the row to examine. + * @param {UrlbarResult} result The result we'd like to apply. + * @param {boolean} seenSearchSuggestion Whether the view update has + * encountered an existing row with a search suggestion result. + * @returns {boolean} Whether the row can be updated to this result. + */ + #rowCanUpdateToResult(rowIndex, result, seenSearchSuggestion) { + // The heuristic result must always be current, thus it's always compatible. + if (result.heuristic) { + return true; + } + let row = this.#rows.children[rowIndex]; + if (result.hasSuggestedIndex != row.result.hasSuggestedIndex) { + // Don't replace a suggestedIndex result with a non-suggestedIndex result + // or vice versa. + return false; + } + if ( + result.hasSuggestedIndex && + result.suggestedIndex != row.result.suggestedIndex + ) { + // Don't replace a suggestedIndex result with another suggestedIndex + // result if the suggestedIndex values are different. + return false; + } + let resultIsSearchSuggestion = this.#resultIsSearchSuggestion(result); + // If the row is same type, just update it. + if ( + resultIsSearchSuggestion == this.#resultIsSearchSuggestion(row.result) + ) { + return true; + } + // If the row has a different type, update it if we are in a compatible + // index range. + // In practice we don't want to overwrite a search suggestion with a non + // search suggestion, but we allow the opposite. + return resultIsSearchSuggestion && seenSearchSuggestion; + } + + #updateResults() { + // TODO: For now this just compares search suggestions to the rest, in the + // future we should make it support any type of result. Or, even better, + // results should be grouped, thus we can directly update groups. + + // Walk rows and find an insertion index for results. To avoid flicker, we + // skip rows until we find one compatible with the result we want to apply. + // If we couldn't find a compatible range, we'll just update. + let results = this.#queryContext.results; + if (results[0]?.heuristic && !this.#shouldShowHeuristic(results[0])) { + // Exclude the heuristic. + results = results.slice(1); + } + let rowIndex = 0; + let resultIndex = 0; + let visibleSpanCount = 0; + let seenMisplacedResult = false; + let seenSearchSuggestion = false; + + // We can have more rows than the visible ones. + for ( + ; + rowIndex < this.#rows.children.length && resultIndex < results.length; + ++rowIndex + ) { + let row = this.#rows.children[rowIndex]; + if (this.#isElementVisible(row)) { + visibleSpanCount += lazy.UrlbarUtils.getSpanForResult(row.result); + } + // Continue updating rows as long as we haven't encountered a new + // suggestedIndex result that couldn't replace a current result. + if (!seenMisplacedResult) { + let result = results[resultIndex]; + // skip this result if it is supposed to be hidden from the view. + if (result.exposureResultHidden) { + this.#addExposure(result); + resultIndex++; + continue; + } + seenSearchSuggestion = + seenSearchSuggestion || + (!row.result.heuristic && this.#resultIsSearchSuggestion(row.result)); + if ( + this.#rowCanUpdateToResult(rowIndex, result, seenSearchSuggestion) + ) { + // We can replace the row's current result with the new one. + this.#updateRow(row, result); + resultIndex++; + continue; + } + if (result.hasSuggestedIndex || row.result.hasSuggestedIndex) { + seenMisplacedResult = true; + } + } + row.setAttribute("stale", "true"); + } + + // Mark all the remaining rows as stale and update the visible span count. + // We include stale rows in the count because we should never show more than + // maxResults spans at one time. Later we'll remove stale rows and unhide + // excess non-stale rows. + for (; rowIndex < this.#rows.children.length; ++rowIndex) { + let row = this.#rows.children[rowIndex]; + row.setAttribute("stale", "true"); + if (this.#isElementVisible(row)) { + visibleSpanCount += lazy.UrlbarUtils.getSpanForResult(row.result); + } + } + + // Add remaining results, if we have fewer rows than results. + for (; resultIndex < results.length; ++resultIndex) { + let result = results[resultIndex]; + // skip this result if it is supposed to be hidden from the view. + if (result.exposureResultHidden) { + this.#addExposure(result); + continue; + } + let row = this.#createRow(); + this.#updateRow(row, result); + if (!seenMisplacedResult && result.hasSuggestedIndex) { + if (result.isSuggestedIndexRelativeToGroup) { + // We can't know at this point what the right index of a group- + // relative suggestedIndex result will be. To avoid all all possible + // flicker, don't make it (and all rows after it) visible until stale + // rows are removed. + seenMisplacedResult = true; + } else { + // We need to check whether the new suggestedIndex result will end up + // at its right index if we append it here. The "right" index is the + // final index the result will occupy once the update is done and all + // stale rows have been removed. We could use a more flexible + // definition, but we use this strict one in order to avoid all + // perceived flicker and movement of suggestedIndex results. Once + // stale rows are removed, the final number of rows in the view will + // be the new result count, so we base our arithmetic here on it. + let finalIndex = + result.suggestedIndex >= 0 + ? Math.min(results.length - 1, result.suggestedIndex) + : Math.max(0, results.length + result.suggestedIndex); + if (this.#rows.children.length != finalIndex) { + seenMisplacedResult = true; + } + } + } + let newVisibleSpanCount = + visibleSpanCount + lazy.UrlbarUtils.getSpanForResult(result); + if ( + newVisibleSpanCount <= this.#queryContext.maxResults && + !seenMisplacedResult + ) { + // The new row can be visible. + visibleSpanCount = newVisibleSpanCount; + } else { + // The new row must be hidden at first because the view is already + // showing maxResults spans, or we encountered a new suggestedIndex + // result that couldn't be placed in the right spot. We'll show it when + // stale rows are removed. + this.#setRowVisibility(row, false); + } + this.#rows.appendChild(row); + } + + this.#updateIndices(); + } + + #createRow() { + let item = this.#createElement("div"); + item.className = "urlbarView-row"; + item._elements = new Map(); + item._buttons = new Map(); + + // A note about row selection. Any element in a row that can be selected + // will have the `selectable` attribute set on it. For typical rows, the + // selectable element is not the `.urlbarView-row` itself but rather the + // `.urlbarView-row-inner` inside it. That's because the `.urlbarView-row` + // also contains the row's buttons, which should not be selected when the + // main part of the row -- `.urlbarView-row-inner` -- is selected. + // + // Since it's the row itself and not the row-inner that is a child of the + // `role=listbox` element (the rows container, `this.#rows`), screen readers + // will not automatically recognize the row-inner as a listbox option. To + // compensate, we set `role=option` on the row-inner and `role=presentation` + // on the row itself so that screen readers ignore it. + item.setAttribute("role", "presentation"); + + return item; + } + + #createRowContent(item, result) { + // The url is the only element that can wrap, thus all the other elements + // are child of noWrap. + let noWrap = this.#createElement("span"); + noWrap.className = "urlbarView-no-wrap"; + item._content.appendChild(noWrap); + + let favicon = this.#createElement("img"); + favicon.className = "urlbarView-favicon"; + noWrap.appendChild(favicon); + item._elements.set("favicon", favicon); + + let typeIcon = this.#createElement("span"); + typeIcon.className = "urlbarView-type-icon"; + noWrap.appendChild(typeIcon); + + let tailPrefix = this.#createElement("span"); + tailPrefix.className = "urlbarView-tail-prefix"; + noWrap.appendChild(tailPrefix); + item._elements.set("tailPrefix", tailPrefix); + // tailPrefix holds text only for alignment purposes so it should never be + // read to screen readers. + tailPrefix.toggleAttribute("aria-hidden", true); + + let tailPrefixStr = this.#createElement("span"); + tailPrefixStr.className = "urlbarView-tail-prefix-string"; + tailPrefix.appendChild(tailPrefixStr); + item._elements.set("tailPrefixStr", tailPrefixStr); + + let tailPrefixChar = this.#createElement("span"); + tailPrefixChar.className = "urlbarView-tail-prefix-char"; + tailPrefix.appendChild(tailPrefixChar); + item._elements.set("tailPrefixChar", tailPrefixChar); + + let title = this.#createElement("span"); + title.classList.add("urlbarView-title", "urlbarView-overflowable"); + noWrap.appendChild(title); + item._elements.set("title", title); + + let tagsContainer = this.#createElement("span"); + tagsContainer.classList.add("urlbarView-tags", "urlbarView-overflowable"); + noWrap.appendChild(tagsContainer); + item._elements.set("tagsContainer", tagsContainer); + + let titleSeparator = this.#createElement("span"); + titleSeparator.className = "urlbarView-title-separator"; + noWrap.appendChild(titleSeparator); + item._elements.set("titleSeparator", titleSeparator); + + let action = this.#createElement("span"); + action.className = "urlbarView-action"; + noWrap.appendChild(action); + item._elements.set("action", action); + + let url = this.#createElement("span"); + url.className = "urlbarView-url"; + item._content.appendChild(url); + item._elements.set("url", url); + } + + /** + * @param {Element} node + * The element to set attributes on. + * @param {object} attributes + * Attribute names to values mapping. For each name-value pair, an + * attribute is set on the element, except for `null` as a value which + * signals an attribute should be removed, and `undefined` in which case + * the attribute won't be set nor removed. The `id` attribute is reserved + * and cannot be set here. + */ + #setDynamicAttributes(node, attributes) { + if (!attributes) { + return; + } + for (let [name, value] of Object.entries(attributes)) { + if (name == "id") { + // IDs are managed externally to ensure they are unique. + console.error( + `Not setting id="${value}", as dynamic attributes may not include IDs.` + ); + continue; + } + if (value === undefined) { + continue; + } + if (value === null) { + node.removeAttribute(name); + } else if (typeof value == "boolean") { + node.toggleAttribute(name, value); + } else { + node.setAttribute(name, value); + } + } + } + + #createRowContentForDynamicType(item, result) { + let { dynamicType } = result.payload; + let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName); + let viewTemplate = + provider.getViewTemplate?.(result) || + UrlbarView.dynamicViewTemplatesByName.get(dynamicType); + if (!viewTemplate) { + console.error(`No viewTemplate found for ${result.providerName}`); + } + this.#buildViewForDynamicType( + dynamicType, + item._content, + item._elements, + viewTemplate + ); + this.#setRowSelectable(item, item._content.hasAttribute("selectable")); + } + + #buildViewForDynamicType(type, parentNode, elementsByName, template) { + // Set attributes on parentNode. + this.#setDynamicAttributes(parentNode, template.attributes); + // Add classes to parentNode's classList. + if (template.classList) { + parentNode.classList.add(...template.classList); + } + if (template.overflowable) { + parentNode.classList.add("urlbarView-overflowable"); + } + if (template.name) { + parentNode.setAttribute("name", template.name); + elementsByName.set(template.name, parentNode); + } + // Recurse into children. + for (let childTemplate of template.children || []) { + let child = this.#createElement(childTemplate.tag); + child.classList.add(`urlbarView-dynamic-${type}-${childTemplate.name}`); + parentNode.appendChild(child); + this.#buildViewForDynamicType(type, child, elementsByName, childTemplate); + } + } + + #createRowContentForBestMatch(item, result) { + item._content.toggleAttribute("selectable", true); + + let favicon = this.#createElement("img"); + favicon.className = "urlbarView-favicon"; + item._content.appendChild(favicon); + item._elements.set("favicon", favicon); + + let typeIcon = this.#createElement("span"); + typeIcon.className = "urlbarView-type-icon"; + item._content.appendChild(typeIcon); + + let body = this.#createElement("span"); + body.className = "urlbarView-row-body"; + item._content.appendChild(body); + + let top = this.#createElement("div"); + top.className = "urlbarView-row-body-top"; + body.appendChild(top); + + let noWrap = this.#createElement("div"); + noWrap.className = "urlbarView-row-body-top-no-wrap"; + top.appendChild(noWrap); + item._elements.set("noWrap", noWrap); + + let title = this.#createElement("span"); + title.classList.add("urlbarView-title", "urlbarView-overflowable"); + noWrap.appendChild(title); + item._elements.set("title", title); + + let titleSeparator = this.#createElement("span"); + titleSeparator.className = "urlbarView-title-separator"; + noWrap.appendChild(titleSeparator); + item._elements.set("titleSeparator", titleSeparator); + + let url = this.#createElement("span"); + url.className = "urlbarView-url"; + top.appendChild(url); + item._elements.set("url", url); + + let bottom = this.#createElement("div"); + bottom.className = "urlbarView-row-body-bottom"; + body.appendChild(bottom); + item._elements.set("bottom", bottom); + } + + #createRowContentForRichSuggestion(item, result) { + item._content.toggleAttribute("selectable", true); + + let favicon = this.#createElement("img"); + favicon.className = "urlbarView-favicon"; + item._content.appendChild(favicon); + item._elements.set("favicon", favicon); + + let body = this.#createElement("span"); + body.classList.add("urlbarView-no-wrap", "urlbarView-overflowable"); + item._content.appendChild(body); + + let title = this.#createElement("span"); + title.classList.add("urlbarView-title"); + body.appendChild(title); + item._elements.set("title", title); + + let description = this.#createElement("small"); + description.className = "urlbarView-description"; + body.appendChild(description); + item._elements.set("description", description); + + let titleSeparator = this.#createElement("span"); + titleSeparator.className = "urlbarView-title-separator"; + body.appendChild(titleSeparator); + item._elements.set("titleSeparator", titleSeparator); + + let action = this.#createElement("span"); + action.className = "urlbarView-action"; + body.appendChild(action); + item._elements.set("action", action); + + let url = this.#createElement("span"); + url.className = "urlbarView-url"; + item._content.appendChild(url); + item._elements.set("url", url); + } + + #addRowButtons(item, result) { + for (let i = 0; i < result.payload.buttons?.length; i++) { + this.#addRowButton(item, { + name: i.toString(), + ...result.payload.buttons[i], + }); + } + + // TODO: `buttonText` is intended only for WebExtensions. We should remove + // it and the WebExtensions urlbar API since we're no longer using it. + if (result.payload.buttonText) { + this.#addRowButton(item, { + name: "tip", + url: result.payload.buttonUrl, + }); + item._buttons.get("tip").textContent = result.payload.buttonText; + } + + if (!lazy.UrlbarPrefs.get("resultMenu")) { + if (result.payload.isBlockable) { + this.#addRowButton(item, { + name: "block", + command: "dismiss", + l10n: result.payload.blockL10n, + }); + } + if (result.payload.helpUrl) { + this.#addRowButton(item, { + name: "help", + command: "help", + url: result.payload.helpUrl, + l10n: result.payload.helpL10n, + }); + } + } else if (this.#getResultMenuCommands(result)) { + this.#addRowButton(item, { + name: "menu", + l10n: { + id: result.showFeedbackMenu + ? "urlbar-result-menu-button-feedback" + : "urlbar-result-menu-button", + }, + attributes: lazy.UrlbarPrefs.get("resultMenu.keyboardAccessible") + ? null + : { + "keyboard-inaccessible": true, + }, + }); + } + } + + #addRowButton(item, { name, command, l10n, url, attributes }) { + let button = this.#createElement("span"); + this.#setDynamicAttributes(button, attributes); + button.id = `${item.id}-button-${name}`; + button.classList.add("urlbarView-button", "urlbarView-button-" + name); + button.setAttribute("role", "button"); + button.dataset.name = name; + if (l10n) { + this.#setElementL10n(button, l10n); + } + if (command) { + button.dataset.command = command; + } + if (url) { + button.dataset.url = url; + } + item._buttons.set(name, button); + item.appendChild(button); + } + + // eslint-disable-next-line complexity + #updateRow(item, result) { + let oldResult = item.result; + let oldResultType = item.result && item.result.type; + let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName); + item.result = result; + item.removeAttribute("stale"); + item.id = getUniqueId("urlbarView-row-"); + + let needsNewContent = + oldResultType === undefined || + (oldResultType == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) != + (result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) || + (oldResultType == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC && + result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC && + oldResult.payload.dynamicType != result.payload.dynamicType) || + // Dynamic results that implement getViewTemplate will + // always need updating. + provider?.getViewTemplate || + oldResult.isBestMatch != result.isBestMatch || + oldResult.payload.isRichSuggestion != result.payload.isRichSuggestion || + (!lazy.UrlbarPrefs.get("resultMenu") && + (!!result.payload.helpUrl != item._buttons.has("help") || + !!result.payload.isBlockable != item._buttons.has("block"))) || + !!this.#getResultMenuCommands(result) != item._buttons.has("menu") || + !lazy.ObjectUtils.deepEqual( + oldResult.payload.buttons, + result.payload.buttons + ); + + if (needsNewContent) { + while (item.lastChild) { + item.lastChild.remove(); + } + item._elements.clear(); + item._buttons.clear(); + item._content = this.#createElement("span"); + item._content.className = "urlbarView-row-inner"; + item.appendChild(item._content); + item.removeAttribute("tip-type"); + item.removeAttribute("dynamicType"); + item.removeAttribute("feedback-acknowledgment"); + if (item.result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) { + this.#createRowContentForDynamicType(item, result); + } else if (item.result.isBestMatch) { + this.#createRowContentForBestMatch(item, result); + } else if (result.payload.isRichSuggestion) { + this.#createRowContentForRichSuggestion(item, result); + } else { + this.#createRowContent(item, result); + } + this.#addRowButtons(item, result); + } + item._content.id = item.id + "-inner"; + + if ( + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + !result.payload.providesSearchMode && + !result.payload.inPrivateWindow + ) { + item.setAttribute("type", "search"); + } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { + item.setAttribute("type", "remotetab"); + } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { + item.setAttribute("type", "switchtab"); + } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.TIP) { + item.setAttribute("type", "tip"); + item.setAttribute("tip-type", result.payload.type); + + // Due to role=button, the button and help icon can sometimes become + // focused. We want to prevent that because the input should always be + // focused instead. (This happens when input.search("", { focus: false }) + // is called, a tip is the first result but not heuristic, and the user + // tabs the into the button from the navbar buttons. The input is skipped + // and the focus goes straight to the tip button.) + item.addEventListener("focus", () => this.input.focus(), true); + + if ( + result.providerName == "UrlbarProviderSearchTips" || + result.payload.type == "dismissalAcknowledgment" + ) { + // For a11y, we treat search tips as alerts. We use A11yUtils.announce + // instead of role="alert" because role="alert" will only fire an alert + // event when the alert (or something inside it) is the root of an + // insertion. In this case, the entire tip result gets inserted into the + // a11y tree as a single insertion, so no alert event would be fired. + this.window.A11yUtils.announce(result.payload.titleL10n); + } + } else if (result.source == lazy.UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + item.setAttribute("type", "bookmark"); + } else if (result.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC) { + item.setAttribute("type", "dynamic"); + this.#updateRowForDynamicType(item, result); + return; + } else if (result.providerName == "TabToSearch") { + item.setAttribute("type", "tabtosearch"); + } else if (result.isBestMatch) { + item.setAttribute("type", "bestmatch"); + this.#updateRowForBestMatch(item, result); + return; + } else { + item.removeAttribute("type"); + } + + let favicon = item._elements.get("favicon"); + if ( + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == lazy.UrlbarUtils.RESULT_TYPE.KEYWORD + ) { + favicon.src = this.#iconForResult(result); + } else { + favicon.src = result.payload.icon || lazy.UrlbarUtils.ICON.DEFAULT; + } + + let title = item._elements.get("title"); + this.#setResultTitle(result, title); + + if (result.payload.tail && result.payload.tailOffsetIndex > 0) { + this.#fillTailSuggestionPrefix(item, result); + title.setAttribute("aria-label", result.payload.suggestion); + item.toggleAttribute("tail-suggestion", true); + } else { + item.removeAttribute("tail-suggestion"); + title.removeAttribute("aria-label"); + } + + this.#updateOverflowTooltip(title, result.title); + + let tagsContainer = item._elements.get("tagsContainer"); + if (tagsContainer) { + tagsContainer.textContent = ""; + if (result.payload.tags && result.payload.tags.length) { + tagsContainer.append( + ...result.payload.tags.map((tag, i) => { + const element = this.#createElement("span"); + element.className = "urlbarView-tag"; + this.#addTextContentWithHighlights( + element, + tag, + result.payloadHighlights.tags[i] + ); + return element; + }) + ); + } + } + + let action = item._elements.get("action"); + let actionSetter = null; + let isVisitAction = false; + let setURL = false; + let isRowSelectable = true; + switch (result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-switch-tab", + }); + }; + setURL = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + actionSetter = () => { + this.#removeElementL10n(action); + action.textContent = result.payload.device; + }; + setURL = true; + break; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.payload.inPrivateWindow) { + if (result.payload.isPrivateEngine) { + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-search-in-private-w-engine", + args: { engine: result.payload.engine }, + }); + }; + } else { + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-search-in-private", + }); + }; + } + } else if (result.providerName == "TabToSearch") { + actionSetter = () => { + this.#setElementL10n(action, { + id: result.payload.isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { engine: result.payload.engine }, + }); + }; + } else if (!result.payload.providesSearchMode) { + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-search-w-engine", + args: { engine: result.payload.engine }, + }); + }; + } + break; + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + isVisitAction = result.payload.input.trim() == result.payload.keyword; + break; + case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX: + actionSetter = () => { + this.#removeElementL10n(action); + action.textContent = result.payload.content; + }; + break; + case lazy.UrlbarUtils.RESULT_TYPE.TIP: + isRowSelectable = false; + break; + default: + if (result.heuristic && !result.payload.title) { + isVisitAction = true; + } else if ( + result.providerName != lazy.UrlbarProviderQuickSuggest.name + ) { + setURL = true; + } + break; + } + + this.#setRowSelectable(item, isRowSelectable); + + if (result.providerName == "TabToSearch") { + action.toggleAttribute("slide-in", true); + } else { + action.removeAttribute("slide-in"); + } + + if (result.payload.isPinned) { + item.toggleAttribute("pinned", true); + } else { + item.removeAttribute("pinned"); + } + + if ( + result.payload.isSponsored && + result.type != lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH + ) { + item.toggleAttribute("sponsored", true); + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-sponsored", + }); + }; + } else { + item.removeAttribute("sponsored"); + } + + if ( + result.providerName == lazy.UrlbarProviderQuickSuggest.name && + result.payload.isSponsored + ) { + item.toggleAttribute("firefox-suggest-sponsored", true); + } else { + item.removeAttribute("firefox-suggest-sponsored"); + } + + if (result.payload.isRichSuggestion) { + let description = item._elements.get("description"); + description.textContent = result.payload.description; + item.setAttribute("rich-suggestion", "with-icon"); + } else if (lazy.UrlbarPrefs.get("richSuggestions.featureGate")) { + // We need to modify the layout of standard urlbar results when + // richSuggestions are enabled so the titles align. + item.setAttribute("rich-suggestion", "no-icon"); + } else { + item.removeAttribute("rich-suggestion"); + } + + let url = item._elements.get("url"); + if (setURL) { + item.setAttribute("has-url", "true"); + this.#addTextContentWithHighlights( + url, + result.payload.displayUrl, + result.payloadHighlights.displayUrl || [] + ); + this.#updateOverflowTooltip(url, result.payload.displayUrl); + } else { + item.removeAttribute("has-url"); + url.textContent = ""; + this.#updateOverflowTooltip(url, ""); + } + + if (isVisitAction) { + actionSetter = () => { + this.#setElementL10n(action, { + id: "urlbar-result-action-visit", + }); + }; + title.setAttribute("isurl", "true"); + } else { + title.removeAttribute("isurl"); + } + + if (actionSetter) { + actionSetter(); + item._originalActionSetter = actionSetter; + item.setAttribute("has-action", "true"); + } else { + item._originalActionSetter = () => { + this.#removeElementL10n(action); + action.textContent = ""; + }; + item._originalActionSetter(); + item.removeAttribute("has-action"); + } + + if (!title.hasAttribute("isurl")) { + title.setAttribute("dir", "auto"); + } else { + title.removeAttribute("dir"); + } + } + + #setRowSelectable(item, isRowSelectable) { + item.toggleAttribute("row-selectable", isRowSelectable); + item._content.toggleAttribute("selectable", isRowSelectable); + if (isRowSelectable) { + item._content.setAttribute("role", "option"); + } else { + item._content.removeAttribute("role"); + } + } + + #iconForResult(result, iconUrlOverride = null) { + return ( + (result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY && + (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == lazy.UrlbarUtils.RESULT_TYPE.KEYWORD) && + lazy.UrlbarUtils.ICON.HISTORY) || + iconUrlOverride || + result.payload.icon || + (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.trending && + lazy.UrlbarUtils.ICON.TRENDING) || + ((result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == lazy.UrlbarUtils.RESULT_TYPE.KEYWORD) && + lazy.UrlbarUtils.ICON.SEARCH_GLASS) || + lazy.UrlbarUtils.ICON.DEFAULT + ); + } + + async #updateRowForDynamicType(item, result) { + item.setAttribute("dynamicType", result.payload.dynamicType); + + let idsByName = new Map(); + for (let [name, node] of item._elements) { + node.id = `${item.id}-${name}`; + idsByName.set(name, node.id); + } + + // First, apply highlighting. We do this before updating via getViewUpdate + // so the dynamic provider can override the highlighting by setting the + // textContent of the highlighted node, if it wishes. + for (let [payloadName, highlights] of Object.entries( + result.payloadHighlights + )) { + if (!highlights.length) { + continue; + } + // Highlighting only works if the dynamic element name is the same as the + // highlighted payload property name. + let nodeToHighlight = item.querySelector(`#${item.id}-${payloadName}`); + this.#addTextContentWithHighlights( + nodeToHighlight, + result.payload[payloadName], + highlights + ); + } + + // Get the view update from the result's provider. + let provider = lazy.UrlbarProvidersManager.getProvider(result.providerName); + let viewUpdate = await provider.getViewUpdate(result, idsByName); + if (item.result != result) { + return; + } + + // Update each node in the view by name. + for (let [nodeName, update] of Object.entries(viewUpdate)) { + let node = item.querySelector(`#${item.id}-${nodeName}`); + this.#setDynamicAttributes(node, update.attributes); + if (update.style) { + for (let [styleName, value] of Object.entries(update.style)) { + node.style[styleName] = value; + } + } + if (update.l10n) { + if (update.l10n.cacheable) { + await this.#l10nCache.ensureAll([update.l10n]); + if (item.result != result) { + return; + } + } + this.#setElementL10n(node, update.l10n); + } else if (update.textContent) { + node.textContent = update.textContent; + } + } + } + + #updateRowForBestMatch(item, result) { + this.#setRowSelectable(item, true); + + let favicon = item._elements.get("favicon"); + favicon.src = this.#iconForResult(result); + + let title = item._elements.get("title"); + this.#setResultTitle(result, title); + this.#updateOverflowTooltip(title, result.title); + + let url = item._elements.get("url"); + this.#addTextContentWithHighlights( + url, + result.payload.displayUrl, + result.payloadHighlights.displayUrl || [] + ); + this.#updateOverflowTooltip(url, result.payload.displayUrl); + + let bottom = item._elements.get("bottom"); + if (result.payload.isSponsored) { + this.#setElementL10n(bottom, { id: "urlbar-result-action-sponsored" }); + } else { + this.#removeElementL10n(bottom); + } + } + + /** + * Performs a final pass over all rows in the view after a view update, stale + * rows are removed, and other changes to the number of rows. Sets `rowIndex` + * on each result, updates row labels, and performs other tasks that must be + * deferred until all rows have been updated. + */ + #updateIndices() { + this.visibleResults = []; + + // `currentLabel` is the last-seen row label as we iterate through the rows. + // When we encounter a label that's different from `currentLabel`, we add it + // to the row and set it to `currentLabel`; we remove the labels for all + // other rows, and therefore no label will appear adjacent to itself. (A + // label may appear more than once, but there will be at least one different + // label in between.) Each row's label is determined by `#rowLabel()`. + let currentLabel; + + for (let i = 0; i < this.#rows.children.length; i++) { + let item = this.#rows.children[i]; + item.result.rowIndex = i; + + let visible = this.#isElementVisible(item); + if (visible) { + if (item.result.exposureResultType) { + this.#addExposure(item.result); + } + this.visibleResults.push(item.result); + } + + let newLabel = this.#updateRowLabel(item, visible, currentLabel); + if (newLabel) { + currentLabel = newLabel; + } + } + + let selectableElement = this.#getFirstSelectableElement(); + let uiIndex = 0; + while (selectableElement) { + selectableElement.elementIndex = uiIndex++; + selectableElement = this.#getNextSelectableElement(selectableElement); + } + + if (this.visibleResults.length) { + this.panel.removeAttribute("noresults"); + } else { + this.panel.setAttribute("noresults", "true"); + } + } + + /** + * Sets or removes the group label from a row. Designed to be called + * iteratively over each row. + * + * @param {Element} item + * A row in the view. + * @param {boolean} isVisible + * Whether the row is visible. This can be computed by the method itself, + * but it's a parameter as an optimization since the caller is expected to + * know it. + * @param {object} currentLabel + * The current group label during row iteration. + * @returns {object} + * If the given row should not have a label, returns null. Otherwise returns + * an l10n object for the label's l10n string: `{ id, args }` + */ + #updateRowLabel(item, isVisible, currentLabel) { + let label; + if (isVisible) { + label = this.#rowLabel(item, currentLabel); + if (label && lazy.ObjectUtils.deepEqual(label, currentLabel)) { + label = null; + } + } + + // When the row-inner is selected, screen readers won't naturally read the + // label because it's a pseudo-element of the row, not the row-inner. To + // compensate, for rows that have labels we add an element to the row-inner + // with `aria-label` and no text content. Rows that don't have labels won't + // have this element. + let groupAriaLabel = item._elements.get("groupAriaLabel"); + + if (!label) { + this.#removeElementL10n(item, { attribute: "label" }); + if (groupAriaLabel) { + groupAriaLabel.remove(); + item._elements.delete("groupAriaLabel"); + } + return null; + } + + this.#setElementL10n(item, { + attribute: "label", + id: label.id, + args: label.args, + }); + + if (!groupAriaLabel) { + groupAriaLabel = this.#createElement("span"); + groupAriaLabel.className = "urlbarView-group-aria-label"; + item._content.insertBefore(groupAriaLabel, item._content.firstChild); + item._elements.set("groupAriaLabel", groupAriaLabel); + } + + // `aria-label` must be a string, not an l10n ID, so first fetch the + // localized value and then set it as the attribute. There's no relevant + // aria attribute that uses l10n IDs. + this.#l10nCache.ensure(label).then(() => { + let message = this.#l10nCache.get(label); + groupAriaLabel.setAttribute("aria-label", message?.attributes.label); + }); + + return label; + } + + /** + * Returns the group label to use for a row. Designed to be called iteratively + * over each row. + * + * @param {Element} row + * A row in the view. + * @param {object} currentLabel + * The current group label during row iteration. + * @returns {object} + * If the current row should not have a label, returns null. Otherwise + * returns an l10n object for the label's l10n string: `{ id, args }` + */ + #rowLabel(row, currentLabel) { + if (!lazy.UrlbarPrefs.get("groupLabels.enabled")) { + return null; + } + + if ( + row.result.isBestMatch && + row.result.providerName == lazy.UrlbarProviderQuickSuggest.name && + row.result.payload.telemetryType == "amo" + ) { + return { id: "urlbar-group-addon" }; + } + + if ( + row.result.isBestMatch || + row.result.providerName == lazy.UrlbarProviderWeather.name + ) { + return { id: "urlbar-group-best-match" }; + } + + if (!this.#queryContext?.searchString || row.result.heuristic) { + return null; + } + + if (row.result.providerName == lazy.UrlbarProviderQuickSuggest.name) { + return { id: "urlbar-group-firefox-suggest" }; + } + + switch (row.result.type) { + case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD: + case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + case lazy.UrlbarUtils.RESULT_TYPE.URL: + return { id: "urlbar-group-firefox-suggest" }; + case lazy.UrlbarUtils.RESULT_TYPE.SEARCH: + // Show "{ $engine } suggestions" if it's not the first label. + if (currentLabel && row.result.payload.suggestion) { + let engineName = + row.result.payload.engine || Services.search.defaultEngine.name; + return { + id: "urlbar-group-search-suggestions", + args: { engine: engineName }, + }; + } + break; + case lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC: + if (row.result.providerName == "quickactions") { + return { id: "urlbar-group-quickactions" }; + } + break; + } + + return null; + } + + #setRowVisibility(row, visible) { + row.style.display = visible ? "" : "none"; + if ( + !visible && + row.result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP && + row.result.type != lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + // Reset the overflow state of elements that can overflow in case their + // content changes while they're hidden. When making the row visible + // again, we'll get new overflow events if needed. + this.#setElementOverflowing(row._elements.get("title"), false); + this.#setElementOverflowing(row._elements.get("url"), false); + let tagsContainer = row._elements.get("tagsContainer"); + if (tagsContainer) { + this.#setElementOverflowing(tagsContainer, false); + } + } + } + + /** + * Returns true if the given element and its row are both visible. + * + * @param {Element} element + * An element in the view. + * @returns {boolean} + * True if the given element and its row are both visible. + */ + #isElementVisible(element) { + if (!element || element.style.display == "none") { + return false; + } + let row = this.#getRowFromElement(element); + return row && row.style.display != "none"; + } + + #removeStaleRows() { + let row = this.#rows.lastElementChild; + while (row) { + let next = row.previousElementSibling; + if (row.hasAttribute("stale")) { + row.remove(); + } else { + this.#setRowVisibility(row, true); + } + row = next; + } + this.#updateIndices(); + } + + #startRemoveStaleRowsTimer() { + this.#removeStaleRowsTimer = this.window.setTimeout(() => { + this.#removeStaleRowsTimer = null; + this.#removeStaleRows(); + }, UrlbarView.removeStaleRowsTimeout); + } + + #cancelRemoveStaleRowsTimer() { + if (this.#removeStaleRowsTimer) { + this.window.clearTimeout(this.#removeStaleRowsTimer); + this.#removeStaleRowsTimer = null; + } + } + + #selectElement( + element, + { updateInput = true, setAccessibleFocus = true } = {} + ) { + if (element && !element.matches(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR)) { + throw new Error("Element is not keyboard-selectable"); + } + + if (this.#selectedElement) { + this.#selectedElement.toggleAttribute("selected", false); + this.#selectedElement.removeAttribute("aria-selected"); + this.#getSelectedRow()?.toggleAttribute("selected", false); + } + let row = this.#getRowFromElement(element); + if (element) { + element.toggleAttribute("selected", true); + element.setAttribute("aria-selected", "true"); + if (row?.hasAttribute("row-selectable")) { + row?.toggleAttribute("selected", true); + } + } + this.#setAccessibleFocus(setAccessibleFocus && element); + this.#selectedElement = element; + + let result = row?.result; + if (updateInput) { + let urlOverride = null; + if (element?.classList?.contains("urlbarView-button-help")) { + urlOverride = result.payload.helpUrl; + } else if (element?.classList?.contains("urlbarView-button")) { + // Clear the input when a button is selected. + urlOverride = ""; + } + this.input.setValueFromResult({ result, urlOverride }); + } else { + this.input.setResultForCurrentValue(result); + } + + let provider = lazy.UrlbarProvidersManager.getProvider( + result?.providerName + ); + if (provider) { + provider.tryMethod("onSelection", result, element); + } + } + + /** + * Returns the element closest to the given element that can be + * selected/picked. If the element itself can be selected, it's returned. If + * there is no such element, null is returned. + * + * @param {Element} element + * An element in the view. + * @param {object} [options] + * Options object. + * @param {boolean} [options.byMouse] + * If true, include elements that are only selectable by mouse. + * @returns {Element} + * The closest element that can be picked including the element itself, or + * null if there is no such element. + */ + #getClosestSelectableElement(element, { byMouse = false } = {}) { + let closest = element.closest( + byMouse + ? SELECTABLE_ELEMENT_SELECTOR + : KEYBOARD_SELECTABLE_ELEMENT_SELECTOR + ); + if (closest && this.#isElementVisible(closest)) { + return closest; + } + // When clicking on a gap within a row or on its border or padding, treat + // this as if the main part was clicked. + if ( + element.classList.contains("urlbarView-row") && + element.hasAttribute("row-selectable") + ) { + return element._content; + } + return null; + } + + /** + * Returns true if the given element is keyboard-selectable. + * + * @param {Element} element + * The element to test. + * @returns {boolean} + * True if the element is selectable and false if not. + */ + #isSelectableElement(element) { + return this.#getClosestSelectableElement(element) == element; + } + + /** + * Returns the first keyboard-selectable element in the view. + * + * @returns {Element} + * The first selectable element in the view. + */ + #getFirstSelectableElement() { + let element = this.#rows.firstElementChild; + if (element && !this.#isSelectableElement(element)) { + element = this.#getNextSelectableElement(element); + } + return element; + } + + /** + * Returns the last keyboard-selectable element in the view. + * + * @returns {Element} + * The last selectable element in the view. + */ + #getLastSelectableElement() { + let element = this.#rows.lastElementChild; + if (element && !this.#isSelectableElement(element)) { + element = this.#getPreviousSelectableElement(element); + } + return element; + } + + /** + * Returns the next keyboard-selectable element after the given element. If + * the element is the last selectable element, returns null. + * + * @param {Element} element + * An element in the view. + * @returns {Element} + * The next selectable element after `element` or null if `element` is the + * last selectable element. + */ + #getNextSelectableElement(element) { + let row = this.#getRowFromElement(element); + if (!row) { + return null; + } + + let next = row.nextElementSibling; + let selectables = [ + ...row.querySelectorAll(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR), + ]; + if (selectables.length) { + let index = selectables.indexOf(element); + if (index < selectables.length - 1) { + next = selectables[index + 1]; + } + } + + if (next && !this.#isSelectableElement(next)) { + next = this.#getNextSelectableElement(next); + } + + return next; + } + + /** + * Returns the previous keyboard-selectable element before the given element. + * If the element is the first selectable element, returns null. + * + * @param {Element} element + * An element in the view. + * @returns {Element} + * The previous selectable element before `element` or null if `element` is + * the first selectable element. + */ + #getPreviousSelectableElement(element) { + let row = this.#getRowFromElement(element); + if (!row) { + return null; + } + + let previous = row.previousElementSibling; + let selectables = [ + ...row.querySelectorAll(KEYBOARD_SELECTABLE_ELEMENT_SELECTOR), + ]; + if (selectables.length) { + let index = selectables.indexOf(element); + if (index < 0) { + previous = selectables[selectables.length - 1]; + } else if (index > 0) { + previous = selectables[index - 1]; + } + } + + if (previous && !this.#isSelectableElement(previous)) { + previous = this.#getPreviousSelectableElement(previous); + } + + return previous; + } + + /** + * Returns the currently selected row. Useful when this.#selectedElement may + * be a non-row element, such as a descendant element of RESULT_TYPE.TIP. + * + * @returns {Element} + * The currently selected row, or ancestor row of the currently selected + * item. + */ + #getSelectedRow() { + return this.#getRowFromElement(this.#selectedElement); + } + + /** + * @param {Element} element + * An element that is potentially a row or descendant of a row. + * @returns {Element} + * The row containing `element`, or `element` itself if it is a row. + */ + #getRowFromElement(element) { + return element?.closest(".urlbarView-row"); + } + + #setAccessibleFocus(item) { + if (item) { + this.input.inputField.setAttribute("aria-activedescendant", item.id); + } else { + this.input.inputField.removeAttribute("aria-activedescendant"); + } + } + + /** + * Sets `result`'s title in `titleNode`'s DOM. + * + * @param {UrlbarResult} result + * The result for which the title is being set. + * @param {Element} titleNode + * The DOM node for the result's tile. + */ + #setResultTitle(result, titleNode) { + if (result.payload.titleL10n) { + this.#setElementL10n(titleNode, result.payload.titleL10n); + return; + } + + // TODO: `text` is intended only for WebExtensions. We should remove it and + // the WebExtensions urlbar API since we're no longer using it. + if (result.payload.text) { + titleNode.textContent = result.payload.text; + return; + } + + if (result.payload.providesSearchMode) { + // Keyword offers are the only result that require a localized title. + // We localize the title instead of using the action text as a title + // because some keyword offer results use both a title and action text + // (e.g. tab-to-search). + this.#setElementL10n(titleNode, { + id: "urlbar-result-action-search-w-engine", + args: { engine: result.payload.engine }, + }); + return; + } + + this.#removeElementL10n(titleNode); + this.#addTextContentWithHighlights( + titleNode, + result.title, + result.titleHighlights + ); + } + + /** + * Adds text content to a node, placing substrings that should be highlighted + * inside <em> nodes. + * + * @param {Element} parentNode + * The text content will be added to this node. + * @param {string} textContent + * The text content to give the node. + * @param {Array} highlights + * The matches to highlight in the text. + */ + #addTextContentWithHighlights(parentNode, textContent, highlights) { + parentNode.textContent = ""; + if (!textContent) { + return; + } + highlights = (highlights || []).concat([[textContent.length, 0]]); + let index = 0; + for (let [highlightIndex, highlightLength] of highlights) { + if (highlightIndex - index > 0) { + parentNode.appendChild( + this.document.createTextNode( + textContent.substring(index, highlightIndex) + ) + ); + } + if (highlightLength > 0) { + let strong = this.#createElement("strong"); + strong.textContent = textContent.substring( + highlightIndex, + highlightIndex + highlightLength + ); + parentNode.appendChild(strong); + } + index = highlightIndex + highlightLength; + } + } + + /** + * Adds markup for a tail suggestion prefix to a row. + * + * @param {Element} item + * The node for the result row. + * @param {UrlbarResult} result + * A UrlbarResult representing a tail suggestion. + */ + #fillTailSuggestionPrefix(item, result) { + let tailPrefixStrNode = item._elements.get("tailPrefixStr"); + let tailPrefixStr = result.payload.suggestion.substring( + 0, + result.payload.tailOffsetIndex + ); + tailPrefixStrNode.textContent = tailPrefixStr; + + let tailPrefixCharNode = item._elements.get("tailPrefixChar"); + tailPrefixCharNode.textContent = result.payload.tailPrefix; + } + + #enableOrDisableRowWrap() { + if (getBoundsWithoutFlushing(this.input.textbox).width < 650) { + this.#rows.setAttribute("wrap", "true"); + } else { + this.#rows.removeAttribute("wrap"); + } + } + + /** + * @param {Element} element + * The element + * @returns {boolean} + * Whether we track this element's overflow status in order to fade it out + * and add a tooltip when needed. + */ + #canElementOverflow(element) { + let { classList } = element; + return ( + classList.contains("urlbarView-overflowable") || + classList.contains("urlbarView-url") + ); + } + + /** + * Marks an element as overflowing or not overflowing. + * + * @param {Element} element + * The element + * @param {boolean} overflowing + * Whether the element is overflowing + */ + #setElementOverflowing(element, overflowing) { + element.toggleAttribute("overflow", overflowing); + this.#updateOverflowTooltip(element); + } + + /** + * Sets an overflowing element's tooltip, or removes the tooltip if the + * element isn't overflowing. Also optionally updates the string that should + * be used as the tooltip in case of overflow. + * + * @param {Element} element + * The element + * @param {string} [tooltip] + * The string that should be used in the tooltip. This will be stored and + * re-used next time the element overflows. + */ + #updateOverflowTooltip(element, tooltip) { + if (typeof tooltip == "string") { + element._tooltip = tooltip; + } + if (element.hasAttribute("overflow") && element._tooltip) { + element.setAttribute("title", element._tooltip); + } else { + element.removeAttribute("title"); + } + } + + /** + * If the view is open and showing a single search tip, this method picks it + * and closes the view. This counts as an engagement, so this method should + * only be called due to user interaction. + * + * @param {event} event + * The user-initiated event for the interaction. Should not be null. + * @returns {boolean} + * True if this method picked a tip, false otherwise. + */ + #pickSearchTipIfPresent(event) { + if ( + !this.isOpen || + !this.#queryContext || + this.#queryContext.results.length != 1 + ) { + return false; + } + let result = this.#queryContext.results[0]; + if (result.type != lazy.UrlbarUtils.RESULT_TYPE.TIP) { + return false; + } + let buttons = this.#rows.firstElementChild._buttons; + let tipButton = buttons.get("tip") || buttons.get("0"); + if (!tipButton) { + throw new Error("Expected a tip button"); + } + this.input.pickElement(tipButton, event); + return true; + } + + /** + * Caches some l10n strings used by the view. Strings that are already cached + * are not cached again. + * + * Note: + * Currently strings are never evicted from the cache, so do not cache + * strings whose arguments include the search string or other values that + * can cause the cache to grow unbounded. Suitable strings include those + * without arguments or those whose arguments depend on a small set of + * static values like search engine names. + */ + async #cacheL10nStrings() { + let idArgs = [ + ...this.#cacheL10nIDArgsForSearchService(), + { id: "urlbar-result-action-search-bookmarks" }, + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-in-private" }, + { id: "urlbar-result-action-search-tabs" }, + { id: "urlbar-result-action-switch-tab" }, + { id: "urlbar-result-action-visit" }, + ]; + + if (lazy.UrlbarPrefs.get("groupLabels.enabled")) { + idArgs.push({ id: "urlbar-group-firefox-suggest" }); + if ( + (lazy.UrlbarPrefs.get("bestMatchEnabled") && + lazy.UrlbarPrefs.get("suggest.bestmatch")) || + (lazy.UrlbarPrefs.get("weatherFeatureGate") && + lazy.UrlbarPrefs.get("suggest.weather")) + ) { + idArgs.push({ id: "urlbar-group-best-match" }); + } + } + + if ( + lazy.UrlbarPrefs.get("quickSuggestEnabled") && + lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored") + ) { + idArgs.push({ id: "urlbar-result-action-sponsored" }); + } + + await this.#l10nCache.ensureAll(idArgs); + } + + /** + * A helper for l10n string caching that returns `{ id, args }` objects for + * strings that depend on the search service. + * + * @returns {Array} + * Array of `{ id, args }` objects, possibly empty. + */ + #cacheL10nIDArgsForSearchService() { + // The search service may not be initialized if the user opens the view very + // quickly after startup. Skip caching related strings in that case. Strings + // are cached opportunistically every time the view opens, so they'll be + // cached soon. We could use the search service's async methods, which + // internally await initialization, but that would allow previously cached + // out-of-date strings to appear in the view while the async calls are + // ongoing. Generally there's no reason for our string-caching paths to be + // async and it may even be a bad idea (except for the final necessary + // `this.#l10nCache.ensureAll()` call). + if (!Services.search.hasSuccessfullyInitialized) { + return []; + } + + let idArgs = []; + + let { defaultEngine, defaultPrivateEngine } = Services.search; + let engineNames = [defaultEngine?.name, defaultPrivateEngine?.name].filter( + name => name + ); + + if (defaultPrivateEngine) { + idArgs.push({ + id: "urlbar-result-action-search-in-private-w-engine", + args: { engine: defaultPrivateEngine.name }, + }); + } + + let engineStringIDs = [ + "urlbar-result-action-tabtosearch-web", + "urlbar-result-action-tabtosearch-other-engine", + "urlbar-result-action-search-w-engine", + ]; + for (let id of engineStringIDs) { + idArgs.push(...engineNames.map(name => ({ id, args: { engine: name } }))); + } + + if (lazy.UrlbarPrefs.get("groupLabels.enabled")) { + idArgs.push( + ...engineNames.map(name => ({ + id: "urlbar-group-search-suggestions", + args: { engine: name }, + })) + ); + } + + return idArgs; + } + + /** + * Sets an element's textContent or attribute to a cached l10n string. If the + * string isn't cached, then this falls back to the async `l10n.setAttributes` + * using the given l10n ID and args. The string will pop in as a result, but + * there's no way around it. + * + * @param {Element} element + * The element to set. + * @param {object} options + * Options object. + * @param {string} options.id + * The l10n string ID. + * @param {object} options.args + * The l10n string arguments. + * @param {string} options.attribute + * If you're setting an attribute string, then pass the name of the + * attribute. In that case, the string in the Fluent file should define a + * value for the attribute, like ".foo = My value for the foo attribute". + * If you're setting the element's textContent, then leave this undefined. + * @param {boolean} options.excludeArgsFromCacheKey + * Pass true if the string was cached using a key that excluded arguments. + */ + #setElementL10n( + element, + { + id, + args = undefined, + attribute = undefined, + excludeArgsFromCacheKey = false, + } + ) { + let message = this.#l10nCache.get({ id, args, excludeArgsFromCacheKey }); + if (message) { + element.removeAttribute("data-l10n-id"); + if (attribute) { + element.setAttribute(attribute, message.attributes[attribute]); + } else { + element.textContent = message.value; + } + } else { + if (attribute) { + element.setAttribute("data-l10n-attrs", attribute); + } + this.document.l10n.setAttributes(element, id, args); + } + } + + /** + * Removes textContent and attributes set by `#setElementL10n`. + * + * @param {Element} element + * The element that should be acted on + * @param {object} options + * Options object + * @param {string} [options.attribute] + * If you passed an attribute to `#setElementL10n`, then pass it here too. + */ + #removeElementL10n(element, { attribute = undefined } = {}) { + if (attribute) { + element.removeAttribute(attribute); + element.removeAttribute("data-l10n-attrs"); + } else { + element.textContent = ""; + } + element.removeAttribute("data-l10n-id"); + } + + get #isShowingZeroPrefix() { + return !!this.#zeroPrefixStopwatchInstance; + } + + #setIsShowingZeroPrefix(isShowing) { + if (!!isShowing == !!this.#zeroPrefixStopwatchInstance) { + return; + } + + if (!isShowing) { + TelemetryStopwatch.finish( + ZERO_PREFIX_HISTOGRAM_DWELL_TIME, + this.#zeroPrefixStopwatchInstance + ); + this.#zeroPrefixStopwatchInstance = null; + return; + } + + this.#zeroPrefixStopwatchInstance = {}; + TelemetryStopwatch.start( + ZERO_PREFIX_HISTOGRAM_DWELL_TIME, + this.#zeroPrefixStopwatchInstance + ); + + Services.telemetry.scalarAdd(ZERO_PREFIX_SCALAR_EXPOSURE, 1); + } + + /** + * @param {UrlbarResult} result + * The result to get menu commands for. + * @returns {Array} + * Array of menu commands available for the result, null if there are none. + */ + #getResultMenuCommands(result) { + if (!lazy.UrlbarPrefs.get("resultMenu")) { + return null; + } + if (this.#resultMenuCommands.has(result)) { + return this.#resultMenuCommands.get(result); + } + + let commands = lazy.UrlbarProvidersManager.getProvider( + result.providerName + )?.tryMethod("getResultCommands", result); + if (commands) { + this.#resultMenuCommands.set(result, commands); + return commands; + } + + commands = []; + if ( + result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY && + !result.autofill + ) { + commands.push( + { + name: RESULT_MENU_COMMANDS.DISMISS, + l10n: { id: "urlbar-result-menu-remove-from-history" }, + }, + { + name: RESULT_MENU_COMMANDS.HELP, + l10n: { id: "urlbar-result-menu-learn-more" }, + } + ); + } + if (result.payload.isBlockable) { + commands.push({ + name: RESULT_MENU_COMMANDS.DISMISS, + l10n: result.payload.blockL10n, + }); + } + if (result.payload.helpUrl) { + commands.push({ + name: RESULT_MENU_COMMANDS.HELP, + l10n: result.payload.helpL10n, + }); + } + let rv = commands.length ? commands : null; + this.#resultMenuCommands.set(result, rv); + return rv; + } + + #populateResultMenu( + menupopup = this.resultMenu, + commands = this.#getResultMenuCommands(this.#resultMenuResult) + ) { + menupopup.textContent = ""; + for (let data of commands) { + if (data.children) { + let popup = this.document.createXULElement("menupopup"); + this.#populateResultMenu(popup, data.children); + let menu = this.document.createXULElement("menu"); + this.#setElementL10n(menu, data.l10n); + menu.appendChild(popup); + menupopup.appendChild(menu); + continue; + } + if (data.name == "separator") { + menupopup.appendChild(this.document.createXULElement("menuseparator")); + continue; + } + let menuitem = this.document.createXULElement("menuitem"); + menuitem.dataset.command = data.name; + menuitem.classList.add("urlbarView-result-menuitem"); + this.#setElementL10n(menuitem, data.l10n); + menupopup.appendChild(menuitem); + } + } + + // Event handlers below. + + on_SelectedOneOffButtonChanged() { + if (!this.isOpen || !this.#queryContext) { + return; + } + + let engine = this.oneOffSearchButtons.selectedButton?.engine; + let source = this.oneOffSearchButtons.selectedButton?.source; + + let localSearchMode; + if (source) { + localSearchMode = lazy.UrlbarUtils.LOCAL_SEARCH_MODES.find( + m => m.source == source + ); + } + + for (let item of this.#rows.children) { + let result = item.result; + + let isPrivateSearchWithoutPrivateEngine = + result.payload.inPrivateWindow && !result.payload.isPrivateEngine; + let isSearchHistory = + result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == lazy.UrlbarUtils.RESULT_SOURCE.HISTORY; + let isSearchSuggestion = result.payload.suggestion && !isSearchHistory; + + // For one-off buttons having a source, we update the action for the + // heuristic result, or for any non-heuristic that is a remote search + // suggestion or a private search with no private engine. + if ( + !result.heuristic && + !isSearchSuggestion && + !isPrivateSearchWithoutPrivateEngine + ) { + continue; + } + + // If there is no selected button and we are in full search mode, it is + // because the user just confirmed a one-off button, thus starting a new + // query. Don't change the heuristic result because it would be + // immediately replaced with the search mode heuristic, causing flicker. + if ( + result.heuristic && + !engine && + !localSearchMode && + this.input.searchMode && + !this.input.searchMode.isPreview + ) { + continue; + } + + let action = item.querySelector(".urlbarView-action"); + let favicon = item.querySelector(".urlbarView-favicon"); + let title = item.querySelector(".urlbarView-title"); + + // If a one-off button is the only selection, force the heuristic result + // to show its action text, so the engine name is visible. + if ( + result.heuristic && + !this.selectedElement && + (localSearchMode || engine) + ) { + item.setAttribute("show-action-text", "true"); + } else { + item.removeAttribute("show-action-text"); + } + + // If an engine is selected, update search results to use that engine. + // Otherwise, restore their original engines. + if (result.type == lazy.UrlbarUtils.RESULT_TYPE.SEARCH) { + if (engine) { + if (!result.payload.originalEngine) { + result.payload.originalEngine = result.payload.engine; + } + result.payload.engine = engine.name; + } else if (result.payload.originalEngine) { + result.payload.engine = result.payload.originalEngine; + delete result.payload.originalEngine; + } + } + + // If the result is the heuristic and a one-off is selected (i.e., + // localSearchMode || engine), then restyle it to look like a search + // result; otherwise, remove such styling. For restyled results, we + // override the usual result-picking behaviour in UrlbarInput.pickResult. + if (result.heuristic) { + title.textContent = + localSearchMode || engine + ? this.#queryContext.searchString + : result.title; + + // Set the restyled-search attribute so the action text and title + // separator are shown or hidden via CSS as appropriate. + if (localSearchMode || engine) { + item.setAttribute("restyled-search", "true"); + } else { + item.removeAttribute("restyled-search"); + } + } + + // Update result action text. + if (localSearchMode) { + // Update the result action text for a local one-off. + let name = lazy.UrlbarUtils.getResultSourceName(localSearchMode.source); + this.#setElementL10n(action, { + id: `urlbar-result-action-search-${name}`, + }); + if (result.heuristic) { + item.setAttribute("source", name); + } + } else if (engine && !result.payload.inPrivateWindow) { + // Update the result action text for an engine one-off. + this.#setElementL10n(action, { + id: "urlbar-result-action-search-w-engine", + args: { engine: engine.name }, + }); + } else { + // No one-off is selected. If we replaced the action while a one-off + // button was selected, it should be restored. + if (item._originalActionSetter) { + item._originalActionSetter(); + if (result.heuristic) { + favicon.src = result.payload.icon || lazy.UrlbarUtils.ICON.DEFAULT; + } + } else { + console.error("An item is missing the action setter"); + } + item.removeAttribute("source"); + } + + // Update result favicons. + let iconOverride = localSearchMode?.icon || engine?.iconURI?.spec; + if (!iconOverride && (localSearchMode || engine)) { + // For one-offs without an icon, do not allow restyled URL results to + // use their own icons. + iconOverride = lazy.UrlbarUtils.ICON.SEARCH_GLASS; + } + if ( + result.heuristic || + (result.payload.inPrivateWindow && !result.payload.isPrivateEngine) + ) { + // If we just changed the engine from the original engine and it had an + // icon, then make sure the result now uses the new engine's icon or + // failing that the default icon. If we changed it back to the original + // engine, go back to the original or default icon. + favicon.src = this.#iconForResult(result, iconOverride); + } + } + } + + on_blur(event) { + // If the view is open without the input being focused, it will not close + // automatically when the window loses focus. We might be in this state + // after a Search Tip is shown on an engine homepage. + if (!lazy.UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.close(); + } + } + + on_mousedown(event) { + if (event.button == 2) { + // Ignore right clicks. + return; + } + + let element = this.#getClosestSelectableElement(event.target, { + byMouse: true, + }); + if (!element) { + // Ignore clicks on elements that can't be selected/picked. + return; + } + + this.window.top.addEventListener("mouseup", this); + + // Select the element and open a speculative connection unless it's a + // button. Buttons are special in the two ways listed below. Some buttons + // may be exceptions to these two criteria, but to provide a consistent UX + // and avoid complexity, we apply this logic to all of them. + // + // (1) Some buttons do not close the view when clicked, like the block and + // menu buttons. Clicking these buttons should not have any side effects in + // the view or input beyond their primary purpose. For example, the block + // button should remove the row but it should not change the input value or + // page proxy state, and ideally it shouldn't change the input's selection + // or caret either. It probably also shouldn't change the view's selection + // (if the removed row isn't selected), but that may be more debatable. + // + // It may be possible to select buttons on mousedown and then clear the + // selection on mouseup as usual while meeting these requirements. However, + // it's not simple because clearing the selection has surprising side + // effects in the input like the ones mentioned above. + // + // (2) Most buttons don't have URLs, so there's nothing to speculatively + // connect to. If a button does have a URL, it's typically different from + // the primary URL of its related result, so it's not critical to open a + // speculative connection anyway. + if (!element.classList.contains("urlbarView-button")) { + this.#mousedownSelectedElement = element; + this.#selectElement(element, { updateInput: false }); + this.controller.speculativeConnect( + this.selectedResult, + this.#queryContext, + "mousedown" + ); + } + } + + on_mouseup(event) { + if (event.button == 2) { + // Ignore right clicks. + return; + } + + this.window.top.removeEventListener("mouseup", this); + + // When mouseup outside of browser, as the target will not be element, + // ignore it. + let element = + event.target.nodeType === event.target.ELEMENT_NODE + ? this.#getClosestSelectableElement(event.target, { byMouse: true }) + : null; + if (element) { + this.input.pickElement(element, event); + } + + // If the element that was selected on mousedown is still in the view, clear + // the selection. Do it after calling `pickElement()` above since code that + // reacts to picks may assume the selected element is the picked element. + // + // If the element is no longer in the view, then it must be because its row + // was removed in response to the pick. If the element was not a button, we + // selected it on mousedown and then `onQueryResultRemoved()` selected the + // next row; we shouldn't unselect it here. If the element was a button, + // then we didn't select anything on mousedown; clearing the selection seems + // like it would be harmless, but it has side effects in the input we want + // to avoid (see `on_mousedown()`). + if (this.#mousedownSelectedElement?.isConnected) { + this.#selectElement(null); + } + this.#mousedownSelectedElement = null; + } + + on_overflow(event) { + if ( + event.detail == 1 /* horizontal overflow */ && + this.#canElementOverflow(event.target) + ) { + this.#setElementOverflowing(event.target, true); + } + } + + on_underflow(event) { + if ( + event.detail == 1 /* horizontal underflow */ && + this.#canElementOverflow(event.target) + ) { + this.#setElementOverflowing(event.target, false); + } + } + + on_resize() { + this.#enableOrDisableRowWrap(); + } + + on_command(event) { + if (event.currentTarget == this.resultMenu) { + let result = this.#resultMenuResult; + this.#resultMenuResult = null; + let menuitem = event.target; + switch (menuitem.dataset.command) { + case RESULT_MENU_COMMANDS.HELP: + menuitem.dataset.url = + result.payload.helpUrl || + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu"; + break; + } + this.input.pickResult(result, event, menuitem); + } + } + + on_popupshowing(event) { + if (event.target == this.resultMenu) { + this.#populateResultMenu(); + } + } + + /** + * Add result to exposure set on the controller. + * + * @param {UrlbarResult} result UrlbarResult for which to record an exposure. + */ + #addExposure(result) { + this.controller.engagementEvent.addExposure(result); + } +} + +/** + * Implements a QueryContext cache, working as a circular buffer, when a new + * entry is added at the top, the last item is remove from the bottom. + */ +class QueryContextCache { + #cache; + #size; + #topSitesContext; + #topSitesListener; + + /** + * Constructor. + * + * @param {number} size The number of entries to keep in the cache. + */ + constructor(size) { + this.#size = size; + this.#cache = []; + + // We store the top-sites context separately since it will often be needed + // and therefore shouldn't be evicted except when the top sites change. + this.#topSitesContext = null; + this.#topSitesListener = () => (this.#topSitesContext = null); + lazy.UrlbarProviderTopSites.addTopSitesListener(this.#topSitesListener); + } + + /** + * @returns {number} The number of entries to keep in the cache. + */ + get size() { + return this.#size; + } + + /** + * @returns {UrlbarQueryContext} The cached top-sites context or null if none. + */ + get topSitesContext() { + return this.#topSitesContext; + } + + /** + * Adds a new entry to the cache. + * + * @param {UrlbarQueryContext} queryContext The UrlbarQueryContext to add. + * Note: QueryContexts without results are ignored and not added. Contexts + * with an empty searchString that are not the top-sites context are + * also ignored. + */ + put(queryContext) { + if (!queryContext.results.length) { + return; + } + + let searchString = queryContext.searchString; + if (!searchString) { + // Cache the context if it's the top-sites context. An empty search string + // doesn't necessarily imply top sites since there are other queries that + // use it too, like search mode. If any result is from the top-sites + // provider, assume the context is top sites. + if ( + queryContext.results?.some( + r => r.providerName == lazy.UrlbarProviderTopSites.name + ) + ) { + this.#topSitesContext = queryContext; + } + return; + } + + let index = this.#cache.findIndex(e => e.searchString == searchString); + if (index != -1) { + if (this.#cache[index] == queryContext) { + return; + } + this.#cache.splice(index, 1); + } + if (this.#cache.unshift(queryContext) > this.size) { + this.#cache.length = this.size; + } + } + + get(searchString) { + return this.#cache.find(e => e.searchString == searchString); + } +} + +/** + * Adds a dynamic result type stylesheet to a specified window. + * + * @param {Window} window + * The window to which to add the stylesheet. + * @param {string} stylesheetURL + * The stylesheet's URL. + */ +async function addDynamicStylesheet(window, stylesheetURL) { + // Try-catch all of these so that failing to load a stylesheet doesn't break + // callers and possibly the urlbar. If a stylesheet does fail to load, the + // dynamic results that depend on it will appear broken, but at least we + // won't break the whole urlbar. + try { + let uri = Services.io.newURI(stylesheetURL); + let sheet = await lazy.styleSheetService.preloadSheetAsync( + uri, + Ci.nsIStyleSheetService.AGENT_SHEET + ); + window.windowUtils.addSheet(sheet, Ci.nsIDOMWindowUtils.AGENT_SHEET); + } catch (ex) { + console.error(`Error adding dynamic stylesheet: ${ex}`); + } +} + +/** + * Removes a dynamic result type stylesheet from the view's window. + * + * @param {Window} window + * The window from which to remove the stylesheet. + * @param {string} stylesheetURL + * The stylesheet's URL. + */ +function removeDynamicStylesheet(window, stylesheetURL) { + // Try-catch for the same reason as desribed in addDynamicStylesheet. + try { + window.windowUtils.removeSheetUsingURIString( + stylesheetURL, + Ci.nsIDOMWindowUtils.AGENT_SHEET + ); + } catch (ex) { + console.error(`Error removing dynamic stylesheet: ${ex}`); + } +} diff --git a/browser/components/urlbar/content/firefoxSuggest.ftl b/browser/components/urlbar/content/firefoxSuggest.ftl new file mode 100644 index 0000000000..6cd1a710d4 --- /dev/null +++ b/browser/components/urlbar/content/firefoxSuggest.ftl @@ -0,0 +1,280 @@ +# 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/. + +### These strings are related to the Firefox Suggest feature. Firefox Suggest +### shows recommended and sponsored third-party results in the address bar +### panel. It also shows headings/labels above different groups of results. For +### example, a "Firefox Suggest" label is shown above bookmarks and history +### results, and an "{ $engine } Suggestions" label may be shown above search +### suggestion results. + +## These strings are used in the urlbar panel. + +# A label shown above the top pick group in the urlbar results. +urlbar-group-best-match = + .label = Top pick + +# Label shown above an extension suggestion in the urlbar results (an +# alternative phrasing is "Extension for Firefox"). It's singular since only one +# suggested extension is displayed. +urlbar-group-addon = + .label = { -brand-product-name } extension + +# Tooltip text for the block button shown in top pick rows. +firefox-suggest-urlbar-block = + .title = Dismiss this suggestion + +# Block menu item shown in the result menu of top pick and quick suggest +# results. +urlbar-result-menu-dismiss-firefox-suggest = + .label = Dismiss this suggestion + .accesskey = D + +# Tooltip text for the help button shown in Firefox Suggest urlbar results. +firefox-suggest-urlbar-learn-more = + .title = Learn more about { -firefox-suggest-brand-name } + +# Learn More menu item shown in the result menu of Firefox Suggest results. +urlbar-result-menu-learn-more-about-firefox-suggest = + .label = Learn more about { -firefox-suggest-brand-name } + .accesskey = L + +# A message shown in a result when the user gives feedback on it. +firefox-suggest-feedback-acknowledgment = Thanks for your feedback + +# A message that replaces a result when the user dismisses it. +firefox-suggest-dismissal-acknowledgment = Thanks for your feedback. You won’t see this again. + +## These strings are used for weather suggestions in the urlbar. + +# This string is displayed above the current temperature +firefox-suggest-weather-currently = Currently + +# This string displays the current temperature value and unit +# Variables: +# $value (number) - The temperature value +# $unit (String) - The unit for the temperature +firefox-suggest-weather-temperature = { $value }°{ $unit } + +# This string is the title of the weather summary +# Variables: +# $city (String) - The name of the city the weather data is for +firefox-suggest-weather-title = Weather for { $city } + +# This string displays the weather summary +# Variables: +# $currentConditions (String) - The current weather conditions summary +# $forecast (String) - The forecast weather conditions summary +firefox-suggest-weather-summary-text = { $currentConditions }; { $forecast } + +# This string displays the high and low temperatures +# Variables: +# $high (number) - The number for the high temperature +# $unit (String) - The unit for the temperature +# $low (number) - The number for the low temperature +firefox-suggest-weather-high-low = High: { $high }°{ $unit } · Low: { $low }°{ $unit } + +# This string displays the name of the weather provider +# Variables: +# $provider (String) - The name of the weather provider +firefox-suggest-weather-sponsored = { $provider } · Sponsored + +firefox-suggest-command-show-less-frequently = + .label = Show less frequently +firefox-suggest-command-dont-show-this = + .label = Don’t show this +firefox-suggest-command-not-relevant = + .label = Not relevant +firefox-suggest-command-not-interested = + .label = Not interested +firefox-suggest-weather-command-inaccurate-location = + .label = Report inaccurate location + +# This string displays the number of reviews in the add-ons suggestion +# Variables: +# $quantity (number) - The number of reviews for the add-on. +firefox-suggest-addons-reviews = + { $quantity -> + [one] { $quantity } review + *[other] { $quantity } reviews + } + +# This string explaining that the add-on suggestion is a recommendation. +firefox-suggest-addons-recommended = Recommended + +## These strings are used in the preferences UI (about:preferences). Their names +## follow the naming conventions of other strings used in the preferences UI. + +# When the user is enrolled in a Firefox Suggest rollout, this text replaces +# the usual addressbar-header string and becomes the text of the address bar +# section in the preferences UI. +addressbar-header-firefox-suggest = Address Bar — { -firefox-suggest-brand-name } + +# When the user is enrolled in a Firefox Suggest rollout, this text replaces +# the usual addressbar-suggest string and becomes the text of the description of +# the address bar section in the preferences UI. +addressbar-suggest-firefox-suggest = Choose the type of suggestions that appear in the address bar: + +# The best match checkbox label. This toggle controls best match suggestions +# related to the user's search string. +addressbar-firefox-suggest-best-match-option = + .label = Top pick +addressbar-best-match-learn-more = Learn more + +# First Firefox Suggest toggle button main label and description. This toggle +# controls non-sponsored suggestions related to the user's search string. +addressbar-firefox-suggest-nonsponsored = + .label = Suggestions from the web + .description = Get suggestions from { -brand-product-name } related to your search. + +# Second Firefox Suggest toggle button main label and description. This toggle +# controls sponsored suggestions related to the user's search string. +addressbar-firefox-suggest-sponsored = + .label = Suggestions from sponsors + .description = Support the development of { -brand-short-name } with occasional sponsored suggestions. + +# Third Firefox Suggest toggle button main label and description. This toggle +# controls data collection related to the user's search string. +addressbar-firefox-suggest-data-collection = + .label = Improve the { -firefox-suggest-brand-name } experience + .description = Help create a richer search experience by allowing { -vendor-short-name } to process your search queries. + +# The "Learn more" link shown in the Firefox Suggest preferences UI. +addressbar-locbar-firefox-suggest-learn-more = Learn more + +## The following addressbar-firefox-suggest-info strings are shown in the +## Firefox Suggest preferences UI in the info box underneath the toggle buttons. +## Each string is shown when a particular toggle combination is active. + +# Non-sponsored suggestions: on +# Sponsored suggestions: on +# Data collection: on +addressbar-firefox-suggest-info-all = Based on your selection, you’ll receive suggestions from the web, including sponsored sites. We will process your search query data to develop the { -firefox-suggest-brand-name } feature. + +# Non-sponsored suggestions: on +# Sponsored suggestions: on +# Data collection: off +addressbar-firefox-suggest-info-nonsponsored-sponsored = Based on your selection, you’ll receive suggestions from the web, including sponsored sites. We won’t process your search query data. + +# Non-sponsored suggestions: on +# Sponsored suggestions: off +# Data collection: on +addressbar-firefox-suggest-info-nonsponsored-data = Based on your selection, you’ll receive suggestions from the web, but no sponsored sites. We will process your search query data to develop the { -firefox-suggest-brand-name } feature. + +# Non-sponsored suggestions: on +# Sponsored suggestions: off +# Data collection: off +addressbar-firefox-suggest-info-nonsponsored = Based on your selection, you’ll receive suggestions from the web, but no sponsored sites. We won’t process your search query data. + +# Non-sponsored suggestions: off +# Sponsored suggestions: on +# Data collection: on +addressbar-firefox-suggest-info-sponsored-data = Based on your selection, you’ll receive sponsored suggestions. We will process your search query data to develop the { -firefox-suggest-brand-name } feature. + +# Non-sponsored suggestions: off +# Sponsored suggestions: on +# Data collection: off +addressbar-firefox-suggest-info-sponsored = Based on your selection, you’ll receive sponsored suggestions. We won’t process your search query data. + +# Non-sponsored suggestions: off +# Sponsored suggestions: off +# Data collection: on +addressbar-firefox-suggest-info-data = Based on your selection, you won’t receive suggestions from the web or sponsored sites. We will process your search query data to develop the { -firefox-suggest-brand-name } feature. + +addressbar-dismissed-suggestions-label = Dismissed suggestions +addressbar-restore-dismissed-suggestions-description = Restore dismissed suggestions from sponsors and the web. +addressbar-restore-dismissed-suggestions-button = + .label = Restore +addressbar-restore-dismissed-suggestions-learn-more = Learn more + +## Used as title on the introduction pane. The text can be formatted to span +## multiple lines as needed (line breaks are significant). + +firefox-suggest-onboarding-introduction-title-1 = + Make sure you’ve got our latest + search experience +firefox-suggest-onboarding-introduction-title-2 = + We’re building a better search experience — + one you can trust +firefox-suggest-onboarding-introduction-title-3 = + We’re building a better way to find what + you’re looking for on the web +firefox-suggest-onboarding-introduction-title-4 = + A faster search experience is in the works +firefox-suggest-onboarding-introduction-title-5 = + Together, we can create the kind of search + experience the Internet deserves +firefox-suggest-onboarding-introduction-title-6 = + Meet { -firefox-suggest-brand-name }, the next + evolution in search +firefox-suggest-onboarding-introduction-title-7 = + Find the best of the web, faster. + +## + +firefox-suggest-onboarding-introduction-close-button = + .title = Close + +firefox-suggest-onboarding-introduction-next-button-1 = Find out how +firefox-suggest-onboarding-introduction-next-button-2 = Find out more +firefox-suggest-onboarding-introduction-next-button-3 = Show me how + +## Used as title on the main pane. The text can be formatted to span +## multiple lines as needed (line breaks are significant). + +firefox-suggest-onboarding-main-title-1 = + We’re building a richer search experience +firefox-suggest-onboarding-main-title-2 = + Help us guide the way to the + best of the Internet +firefox-suggest-onboarding-main-title-3 = + A richer, smarter search experience +firefox-suggest-onboarding-main-title-4 = + Finding the best of the web, faster +firefox-suggest-onboarding-main-title-5 = + We’re building a better search experience — + you can help +firefox-suggest-onboarding-main-title-6 = + It’s time to think outside the search engine +firefox-suggest-onboarding-main-title-7 = + We’re building a smarter search experience — + one you can trust +firefox-suggest-onboarding-main-title-8 = + Finding the best of the web should be + simpler and more secure. +firefox-suggest-onboarding-main-title-9 = + Find the best of the web, faster + +## + +firefox-suggest-onboarding-main-description-1 = Allowing { -vendor-short-name } to process your search queries means you’re helping us create smarter, more relevant search suggestions. And, as always, we’ll keep your privacy top of mind. +firefox-suggest-onboarding-main-description-2 = When you allow { -vendor-short-name } to process your search queries, you’re helping build a better { -firefox-suggest-brand-name } for everyone. And, as always, we’ll keep your privacy top of mind. +firefox-suggest-onboarding-main-description-3 = What if your browser helped you zero in on what you’re actually looking for? Allowing { -vendor-short-name } to process your search queries helps us create more relevant search suggestions that still keep your privacy top of mind. +firefox-suggest-onboarding-main-description-4 = You’re trying to get where you’re going on the web and get on with it. When you allow { -vendor-short-name } to process your search queries, we can help you get there faster—while keeping your privacy top of mind. +firefox-suggest-onboarding-main-description-5 = Allowing { -vendor-short-name } to process your search queries will help us create more relevant suggestions for everyone. And, as always, we’ll keep your privacy top of mind. +firefox-suggest-onboarding-main-description-6 = Allowing { -vendor-short-name } to process your search queries will help us create more relevant search suggestions. We’re building { -firefox-suggest-brand-name } to help you get where you’re going on the Internet while keeping your privacy in mind. +firefox-suggest-onboarding-main-description-7 = Allowing { -vendor-short-name } to process your search queries helps us create more relevant search suggestions. +firefox-suggest-onboarding-main-description-8 = Allowing { -vendor-short-name } to process your search queries helps us provide more relevant search suggestions. We don’t use this data to profile you on the web. +firefox-suggest-onboarding-main-description-9 = + We’re building a better search experience. When you allow { -vendor-short-name } to process your search queries, we can create more relevant search suggestions for you. + <a data-l10n-name="learn-more-link">Learn more</a> + +firefox-suggest-onboarding-main-privacy-first = No user profiling. Privacy-first, always. + +firefox-suggest-onboarding-main-accept-option-label = Allow. <a data-l10n-name="learn-more-link">Learn more</a> +firefox-suggest-onboarding-main-accept-option-label-2 = Enable + +firefox-suggest-onboarding-main-accept-option-description-1 = Help improve the { -firefox-suggest-brand-name } feature with more relevant suggestions. Your search queries will be processed. +firefox-suggest-onboarding-main-accept-option-description-2 = Recommended for people who support improving the { -firefox-suggest-brand-name } feature.
Your search queries will be processed. +firefox-suggest-onboarding-main-accept-option-description-3 = Help improve the { -firefox-suggest-brand-name } experience. Your search queries will be processed. + +firefox-suggest-onboarding-main-reject-option-label = Don’t allow. +firefox-suggest-onboarding-main-reject-option-label-2 = Keep disabled + +firefox-suggest-onboarding-main-reject-option-description-1 = Keep the default { -firefox-suggest-brand-name } experience with the strictest data-sharing controls. +firefox-suggest-onboarding-main-reject-option-description-2 = Recommended for people who prefer the strictest data-sharing controls. Keep the default experience. +firefox-suggest-onboarding-main-reject-option-description-3 = Leave the default { -firefox-suggest-brand-name } experience with the strictest data-sharing controls. + +firefox-suggest-onboarding-main-submit-button = Save preferences +firefox-suggest-onboarding-main-skip-link = Not now diff --git a/browser/components/urlbar/content/interventions.ftl b/browser/components/urlbar/content/interventions.ftl new file mode 100644 index 0000000000..50738ba703 --- /dev/null +++ b/browser/components/urlbar/content/interventions.ftl @@ -0,0 +1,40 @@ +# 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/. + +### These strings appear in Urlbar Interventions. Interventions appear in the +### Urlbar in response to the user's query. For example, if we detect that the +### user is searching how to clear their history, we show the Intervention +### described by clear-data. + +intervention-clear-data = Clear your cache, cookies, history and more. +intervention-clear-data-confirm = Choose What to Clear… +intervention-refresh-profile = Restore default settings and remove old add-ons for optimal performance. +intervention-refresh-profile-confirm = Refresh { -brand-short-name }… + +## These strings describe Interventions helping the user with the Firefox update +## process. +## +## Shown when an update is available to download. + +intervention-update-ask = A new version of { -brand-short-name } is available. +intervention-update-ask-confirm = Install and Restart to Update + +## Shown when Firefox does not need to update so instead we offer to refresh +## the user's profile. + +intervention-update-refresh = { -brand-short-name } is up to date. Trying to fix a problem? Restore default settings and remove old add-ons for optimal performance. +intervention-update-refresh-confirm = Refresh { -brand-short-name }… + +## Shown when an update is downloaded and Firefox is ready to install it. + +intervention-update-restart = The latest { -brand-short-name } is downloaded and ready to install. +intervention-update-restart-confirm = Restart to Update + +## Shown when Firefox cannot update itself. The button will open the download +## page on the Firefox website. + +intervention-update-web = Get the latest { -brand-short-name } browser. +intervention-update-web-confirm = Download Now + +## diff --git a/browser/components/urlbar/content/preloaded-top-urls.json b/browser/components/urlbar/content/preloaded-top-urls.json new file mode 100644 index 0000000000..a94b85d76d --- /dev/null +++ b/browser/components/urlbar/content/preloaded-top-urls.json @@ -0,0 +1,22 @@ +[ + ["https://www.google.com/", "Google"], + ["https://www.youtube.com/", "YouTube"], + ["https://www.facebook.com/", "Facebook"], + ["https://www.baidu.com/", "百度一下"], + ["https://www.yahoo.com/", "Yahoo"], + ["https://www.wikipedia.org/", "Wikipedia"], + ["http://www.qq.com/", "腾讯首页"], + ["http://www.sohu.com/", "搜狐"], + ["https://world.taobao.com/", "淘宝海外全球站"], + ["https://www.tmall.com/", "天猫tmall.com"], + ["https://www.live.com/", "Live"], + ["https://www.amazon.com/", "Amazon.com"], + ["https://vk.com/", "VK"], + ["https://twitter.com/", "Twitter"], + ["https://www.instagram.com/", "Instagram"], + ["http://360.cn/", "360公司官网"], + ["http://www.sina.com.cn/", "新浪首页"], + ["https://www.linkedin.com/", "LinkedIn"], + ["http://www.jd.com/", "京东(JD.COM)"], + ["https://www.reddit.com/", "reddit"] +] diff --git a/browser/components/urlbar/content/quicksuggestOnboarding.css b/browser/components/urlbar/content/quicksuggestOnboarding.css new file mode 100644 index 0000000000..6ed8454398 --- /dev/null +++ b/browser/components/urlbar/content/quicksuggestOnboarding.css @@ -0,0 +1,311 @@ +/* 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/. */ + +/** + * When making changes, follow the example of the AboutWelcome messaging surface for font sizes, line heights, + * etc. See: https://searchfox.org/mozilla-central/source/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss + */ + +:root { + --introduction-magglass-logo-height: 128px; + --introduction-firefox-logo-height: 72px; + --introduction-image-height: 224px; + --main-magglass-logo-height: 64px; + --main-firefox-logo-height: 50px; + --x-large-margin: 40px; + --large-margin: 24px; + --large-margin-const: 24px; + --small-margin: 16px; + --small-margin-const: 16px; + --x-small-margin-const: 8px; + --section-vertical-padding: 32px; + --section-horizontal-padding: 64px; +} + +body.compact { + --introduction-image-height: 183px; + --main-magglass-logo-height: 48px; + --main-firefox-logo-height: 32px; + --x-large-margin: 20px; + --large-margin: 12px; + --small-margin: 8px; + --section-vertical-padding: 16px; + --section-horizontal-padding: 32px; + + /* 15px is the non-compact font-size. */ + font-size: 13px; +} + +body, +section { + width: 536px; +} + +section { + display: flex; + align-items: stretch; + justify-content: center; + flex-direction: column; + text-align: center; + padding: var(--section-vertical-padding) var(--section-horizontal-padding); + /* This is the largest approximate natural height of the main section across + platforms and dialog variations, erring on the side of being slightly + larger than necessary. If you change this, also update COMPACT_MODE_HEIGHT + in the JS. */ + min-height: 650px; +} + +body.compact section { + /* This is the largest approximate natural height of the main section across + platforms and dialog variations in compact mode, erring on the side of + being slightly larger than necessary. */ + min-height: 510px; +} + +a { + cursor: pointer; + font-weight: normal; +} + +.title { + font-size: 1.6em; + font-weight: 600; + line-height: 1.5; + white-space: pre-line; +} + +.logo { + background-repeat: no-repeat; + background-position: center; + background-size: contain; + border: none; +} + +.description-section { + /* The effective visual margin between the description and first option should + be --large-margin-const. Each child in the description has a bottom margin + of --small-margin, so subtract it from --large-margin-const. */ + margin-block: 0 calc(var(--large-margin-const) - var(--small-margin)); +} + +.description { + font-size: 1.1em; + font-weight: 400; + line-height: 1.6; + margin-block: 0 var(--small-margin); + white-space: pre-line; +} + +.privacy-first { + font-size: 1.1em; + font-weight: 700; + margin-block: 0 var(--small-margin); +} + +.pager > span { + display: inline-block; + border-radius: 3px; + width: 6px; + height: 6px; + background-color: var(--in-content-border-color); + margin-inline: 4px; +} + +.pager > .current { + background-color: var(--in-content-primary-button-background); +} + +#introduction-section .logo { + background-image: url("quicksuggestOnboarding_magglass.svg"); + height: var(--introduction-magglass-logo-height); + margin-block-end: var(--large-margin); +} + +#introduction-section .logo.firefox { + background-image: url("chrome://branding/content/about-logo.svg"); + height: var(--introduction-firefox-logo-height); +} + +@media (prefers-reduced-motion: no-preference) { + #introduction-section .logo { + background-image: url("quicksuggestOnboarding_magglass_animation.svg"); + } +} + +#introduction-section .title { + margin-block-end: var(--x-large-margin); +} + +#introduction-image { + height: var(--introduction-image-height); + background-image: url("suggest-example.svg"); + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-block: var(--large-margin); +} + +/* fx100 layout */ +#introduction-section.layout-100 .logo { + height: var(--main-firefox-logo-height); +} + +#introduction-section.layout-100 .title { + margin-block: 0 var(--small-margin); +} + +#introduction-section:not(.layout-100) .description-section { + display: none; +} + +#onboardingClose { + position: absolute; + top: 0; + inset-inline-end: 0; + margin: 16px; + padding: 0; + line-height: 0; + min-width: 20px; + min-height: 20px; +} + +#onboardingClose img { + -moz-context-properties: fill; + fill: currentColor; +} + +#main-section:not(.active) { + display: none; +} + +#main-section .logo { + background-image: url("quicksuggestOnboarding_magglass.svg"); + height: var(--main-magglass-logo-height); + margin-block-end: var(--large-margin); +} + +#main-section .logo.firefox { + background-image: url("chrome://branding/content/about-logo.svg"); + height: var(--main-firefox-logo-height); +} + +#main-section .title { + margin-block: 0 var(--small-margin); +} + +#main-section .privacy-first:not(.active) { + display: none; +} + +#main-section .option { + border-radius: 4px; + border: 2px solid var(--in-content-box-info-background); + display: flex; + text-align: start; + /* Use --small-margin-const for the horizontal padding to make the option's + horizontal padding larger than the vertical padding in compact mode. The + radio button and text are too close to the left and right edges of the + option's border otherwise. */ + padding: var(--small-margin) var(--small-margin-const); + flex-direction: row; +} + +#main-section .option.selected { + border-color: var(--in-content-primary-button-background); +} + +#main-section .option.accept { + margin-block-end: var(--small-margin); +} + +#main-section .option.reject { + margin-block-end: var(--large-margin-const); +} + +#main-section .option > label { + /* Make the whole option area selectable for the radio button. 22px is the + width of the radio button and its inline margin. */ + padding-block: var(--small-margin); + padding-inline-start: calc(22px + var(--small-margin-const)); + padding-inline-end: var(--small-margin-const); + margin-block: calc(-1 * var(--small-margin)); + margin-inline-start: calc(-1 * (22px + var(--small-margin-const))); + margin-inline-end: calc(-1 * var(--small-margin-const)); +} + +body:not(.compact) #main-section .option > input { + /* Vertically align the radio button with the .option-label. */ + margin-block-start: 0.25em; +} + +#main-section .option-label { + font-size: 1.1em; + font-weight: 600; + margin-block-end: 2px; +} + +#main-section .option-description { + font-size: 1em; +} + +.buttonBox { + display: flex; + flex-direction: column; + align-items: center; +} + +button { + margin-block-end: var(--large-margin); +} + +#onboardingSkipLink { + margin-block-end: var(--x-small-margin-const); +} + +/* transition from introduction to main */ +#introduction-section.inactive { + /* Avoid including this section size */ + position: fixed; + pointer-events: none; + animation: fadeout 0.3s forwards; +} + +#main-section.active { + animation: fadein 0.3s forwards; +} + +@keyframes fadeout { + 0% { + opacity: 1; + } + 100% { + visibility: hidden; + opacity: 0; + } +} + +@keyframes fadein { + 0% { + opacity: 0; + } + 100% { + pointer-events: initial; + opacity: 1; + } +} + +/* Show main only without transition */ +body.skip-introduction #introduction-section.inactive { + animation: none; + display: none; +} + +body.skip-introduction #main-section.active { + animation: none; + pointer-events: initial; +} + +body.skip-introduction .pager { + display: none; +} diff --git a/browser/components/urlbar/content/quicksuggestOnboarding.html b/browser/components/urlbar/content/quicksuggestOnboarding.html new file mode 100644 index 0000000000..46247537d8 --- /dev/null +++ b/browser/components/urlbar/content/quicksuggestOnboarding.html @@ -0,0 +1,109 @@ +<!DOCTYPE html> + +<!-- 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/. --> + +<html> + <head> + <meta http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'"> + <meta name="referrer" content="no-referrer"> + <link rel="stylesheet" type="text/css" + href="chrome://global/skin/in-content/common.css"> + <link rel="stylesheet" type="text/css" + href="chrome://browser/content/urlbar/quicksuggestOnboarding.css"> + <link rel="localization" href="branding/brand.ftl"> + <link rel="localization" href="toolkit/branding/brandings.ftl"/> + <link rel="localization" href="preview/firefoxSuggest.ftl"> + <script src="chrome://browser/content/urlbar/quicksuggestOnboarding.js"></script> + </head> + <body role="dialog" aria-labelledby="introduction-title"> + <section id="introduction-section"> + <button id="onboardingClose" + class="ghost-button" + tabindex="3" + data-l10n-id="firefox-suggest-onboarding-introduction-close-button"> + <img src="chrome://global/skin/icons/close.svg" /> + </button> + <span class="logo" role="presentation"></span> + <h1 id="introduction-title" class="title"></h1> + <div class="description-section"> + <h2 id="introduction-description" + class="description" + tabindex="-1" + data-l10n-id="firefox-suggest-onboarding-main-description-9"> + <a id="onboardingLearnMoreOnIntroduction" + tabindex="2" + data-l10n-name="learn-more-link"></a> + </h2> + <h3 id="introduction-privacy-first" + class="privacy-first" + tabindex="-1" + data-l10n-id="firefox-suggest-onboarding-main-privacy-first"></h2> + <div id="introduction-image" role="presentation"></div> + </div> + <div class="buttonBox"> + <button id="onboardingNext" class="primary" tabindex="1"></button> + <div class="pager"> + <span class="current"></span> + <span></span> + </div> + </div> + </section> + <section id="main-section"> + <span class="logo" role="presentation"></span> + <h1 id="main-title" class="title"></h1> + <div class="description-section"> + <h2 id="main-description" class="description" tabindex="-1"> + <a id="onboardingLearnMore" + tabindex="4" + data-l10n-name="learn-more-link"></a> + </h2> + <h3 id="main-privacy-first" + class="privacy-first" + tabindex="-1" + data-l10n-id="firefox-suggest-onboarding-main-privacy-first"></h2> + </div> + <div class="option accept"> + <input id="onboardingAccept" + type="radio" + tabindex="5" + name="search-experience"></input> + <label for="onboardingAccept"> + <div id="main-accept-option-label" class="option-label"> + <a id="onboardingLearnMore" + tabindex="6" + data-l10n-name="learn-more-link"></a> + </div> + <div id="main-accept-option-description" class="option-description"></div> + </label> + </div> + <div class="option reject"> + <input id="onboardingReject" + type="radio" + tabindex="7" + name="search-experience"></input> + <label for="onboardingReject"> + <div id="main-reject-option-label" class="option-label"> + </div> + <div id="main-reject-option-description" class="option-description"></div> + </label> + </div> + <div class="buttonBox"> + <button id="onboardingSubmit" + class="primary" + disabled="true" + tabindex="8" + data-l10n-id="firefox-suggest-onboarding-main-submit-button"></button> + <a id="onboardingSkipLink" + tabindex="9" + data-l10n-id="firefox-suggest-onboarding-main-skip-link"></a> + <div class="pager"> + <span></span> + <span class="current"></span> + </div> + </div> + </section> + </body> +</html> diff --git a/browser/components/urlbar/content/quicksuggestOnboarding.js b/browser/components/urlbar/content/quicksuggestOnboarding.js new file mode 100644 index 0000000000..5b78bd4409 --- /dev/null +++ b/browser/components/urlbar/content/quicksuggestOnboarding.js @@ -0,0 +1,338 @@ +/* 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/. */ + +"use strict"; + +const { QuickSuggest } = ChromeUtils.importESModule( + "resource:///modules/QuickSuggest.sys.mjs" +); +const { ONBOARDING_CHOICE } = QuickSuggest; + +const VARIATION_MAP = { + a: { + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-1", + "main-title": "firefox-suggest-onboarding-main-title-1", + "main-description": "firefox-suggest-onboarding-main-description-1", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + }, + b: { + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + "main-title": "firefox-suggest-onboarding-main-title-2", + "main-description": "firefox-suggest-onboarding-main-description-2", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + }, + c: { + logoType: "firefox", + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-3", + "main-title": "firefox-suggest-onboarding-main-title-3", + "main-description": "firefox-suggest-onboarding-main-description-3", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + }, + d: { + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-4", + "main-title": "firefox-suggest-onboarding-main-title-4", + "main-description": "firefox-suggest-onboarding-main-description-4", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + }, + e: { + logoType: "firefox", + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-5", + "main-title": "firefox-suggest-onboarding-main-title-5", + "main-description": "firefox-suggest-onboarding-main-description-5", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + }, + f: { + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-2", + "introduction-title": "firefox-suggest-onboarding-introduction-title-6", + "main-title": "firefox-suggest-onboarding-main-title-6", + "main-description": "firefox-suggest-onboarding-main-description-6", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + }, + g: { + mainPrivacyFirst: true, + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-7", + "main-title": "firefox-suggest-onboarding-main-title-7", + "main-description": "firefox-suggest-onboarding-main-description-7", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + }, + h: { + logoType: "firefox", + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + "main-title": "firefox-suggest-onboarding-main-title-8", + "main-description": "firefox-suggest-onboarding-main-description-8", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + }, + "100-a": { + introductionLayout: "layout-100", + mainPrivacyFirst: true, + logoType: "firefox", + l10nUpdates: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-3", + "introduction-title": "firefox-suggest-onboarding-main-title-9", + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + }, + "100-b": { + mainPrivacyFirst: true, + logoType: "firefox", + l10nUpdates: { + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + skipIntroduction: true, + }, +}; + +// If the window height is smaller than this value when the dialog opens, then +// the dialog will open in compact mode. The dialog will not change modes while +// it's open even if the window height changes. +const COMPACT_MODE_HEIGHT = + 650 + // section min-height (non-compact mode) + 2 * 32 + // 2 * --section-vertical-padding (non-compact mode) + 44; // approximate height of the browser window's tab bar + +// Used for test only. If links or buttons may be clicked or typed Key_Enter +// while translating l10n, cannot capture the events since not register listeners +// yet. To avoid the issue, add this flag to know the listeners are ready. +let resolveOnboardingReady; +window._quicksuggestOnboardingReady = new Promise(r => { + resolveOnboardingReady = r; +}); + +document.addEventListener("DOMContentLoaded", async () => { + await document.l10n.ready; + + const variation = + VARIATION_MAP[window.arguments[0].variationType] || VARIATION_MAP.a; + + document.l10n.pauseObserving(); + try { + await applyVariation(variation); + } finally { + document.l10n.resumeObserving(); + } + + addSubmitListener(document.getElementById("onboardingClose"), () => { + window.arguments[0].choice = ONBOARDING_CHOICE.CLOSE_1; + window.close(); + }); + addSubmitListener(document.getElementById("onboardingNext"), () => { + gotoMain(variation); + }); + addSubmitListener(document.getElementById("onboardingLearnMore"), () => { + window.arguments[0].choice = ONBOARDING_CHOICE.LEARN_MORE_2; + window.close(); + }); + addSubmitListener( + document.getElementById("onboardingLearnMoreOnIntroduction"), + () => { + window.arguments[0].choice = ONBOARDING_CHOICE.LEARN_MORE_1; + window.close(); + } + ); + addSubmitListener(document.getElementById("onboardingSkipLink"), () => { + window.arguments[0].choice = ONBOARDING_CHOICE.NOT_NOW_2; + window.close(); + }); + + const onboardingSubmit = document.getElementById("onboardingSubmit"); + const onboardingAccept = document.getElementById("onboardingAccept"); + const onboardingReject = document.getElementById("onboardingReject"); + function optionChangeListener() { + onboardingSubmit.removeAttribute("disabled"); + onboardingAccept + .closest(".option") + .classList.toggle("selected", onboardingAccept.checked); + onboardingReject + .closest(".option") + .classList.toggle("selected", !onboardingAccept.checked); + } + onboardingAccept.addEventListener("change", optionChangeListener); + onboardingReject.addEventListener("change", optionChangeListener); + + function submitListener() { + if (!onboardingAccept.checked && !onboardingReject.checked) { + return; + } + + window.arguments[0].choice = onboardingAccept.checked + ? ONBOARDING_CHOICE.ACCEPT_2 + : ONBOARDING_CHOICE.REJECT_2; + window.close(); + } + addSubmitListener(onboardingSubmit, submitListener); + onboardingAccept.addEventListener("keydown", e => { + if (e.keyCode == e.DOM_VK_RETURN) { + submitListener(); + } + }); + onboardingReject.addEventListener("keydown", e => { + if (e.keyCode == e.DOM_VK_RETURN) { + submitListener(); + } + }); + + if (window.outerHeight < COMPACT_MODE_HEIGHT) { + document.body.classList.add("compact"); + } + + resolveOnboardingReady(); +}); + +function gotoMain(variation) { + window.arguments[0].visitedMain = true; + + document.getElementById("introduction-section").classList.add("inactive"); + document.getElementById("main-section").classList.add("active"); + + document.body.setAttribute("aria-labelledby", "main-title"); + let ariaDescribedBy = "main-description"; + if (variation.mainPrivacyFirst) { + ariaDescribedBy += " main-privacy-first"; + } + document.body.setAttribute("aria-describedby", ariaDescribedBy); +} + +async function applyVariation(variation) { + if (variation.logoType) { + for (const logo of document.querySelectorAll(".logo")) { + logo.classList.add(variation.logoType); + } + } + + if (variation.mainPrivacyFirst) { + const label = document.querySelector("#main-section .privacy-first"); + label.classList.add("active"); + } + + if (variation.l10nUpdates) { + const translatedElements = []; + for (const [id, newL10N] of Object.entries(variation.l10nUpdates)) { + const element = document.getElementById(id); + document.l10n.setAttributes(element, newL10N); + translatedElements.push(element); + } + await document.l10n.translateElements(translatedElements); + } + + if (variation.skipIntroduction) { + document.body.classList.add("skip-introduction"); + gotoMain(variation); + } + + if (variation.introductionLayout) { + document + .getElementById("introduction-section") + .classList.add(variation.introductionLayout); + } +} + +function addSubmitListener(element, listener) { + if (!element) { + console.warn("Element is null on addSubmitListener"); + return; + } + element.addEventListener("click", listener); + element.addEventListener("keydown", e => { + if (e.keyCode == e.DOM_VK_RETURN) { + listener(); + } + }); +} diff --git a/browser/components/urlbar/content/quicksuggestOnboarding_magglass.svg b/browser/components/urlbar/content/quicksuggestOnboarding_magglass.svg new file mode 100644 index 0000000000..7e9d140e35 --- /dev/null +++ b/browser/components/urlbar/content/quicksuggestOnboarding_magglass.svg @@ -0,0 +1,34 @@ +<!-- 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 https://mozilla.org/MPL/2.0/. --> +<svg width="158" height="115" viewBox="0 0 158 115" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M120.886 24.4352C120.885 24.1852 120.798 23.9433 120.639 23.7507C120.479 23.558 120.258 23.4268 120.013 23.3792C117.928 23.0168 112.136 21.6535 110.178 19.6977C108.223 17.7418 106.859 11.9768 106.5 9.8665C106.452 9.62121 106.32 9.40009 106.128 9.24045C105.936 9.0808 105.694 8.99243 105.444 8.99023V24.4352H120.886Z" fill="#AB71FF"/> +<path d="M105.525 8.99023C105.27 8.99295 105.025 9.0852 104.831 9.25084C104.637 9.41649 104.507 9.645 104.465 9.89644C104.128 11.9657 102.834 17.6552 100.868 19.7528C98.9015 21.8489 93.1081 23.0893 90.8827 23.4549C90.6355 23.5018 90.4123 23.6333 90.2516 23.8269C90.0908 24.0204 90.0025 24.2639 90.0017 24.5156H105.525V8.99023Z" fill="#9158FF"/> +<path d="M105.347 24.4375V39.9597C105.598 39.9582 105.841 39.8698 106.035 39.7095C106.228 39.5492 106.36 39.3269 106.409 39.0802C106.77 36.9605 108.139 31.0583 110.106 29.0946C112.075 27.1262 117.87 25.8323 119.966 25.4966C120.217 25.4547 120.445 25.3255 120.611 25.1318C120.776 24.9381 120.868 24.6922 120.87 24.4375H105.347Z" fill="#7542E5"/> +<path d="M90 24.4375C89.9977 24.6935 90.0883 24.9417 90.2549 25.1362C90.4215 25.3306 90.6529 25.4581 90.9063 25.495C93.0024 25.8323 98.8998 27.1262 100.867 29.093C102.832 31.0583 104.126 36.8549 104.462 39.0519C104.54 39.5783 104.993 39.9644 105.524 39.9581V24.4375H90Z" fill="#592ACB"/> +<circle cx="73.3988" cy="63.9893" r="50.5" fill="#F9F9FB"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M70.3502 73.7613C62.6751 73.7613 56.4299 67.516 56.4299 59.8408C56.4299 52.1656 62.6751 45.9204 70.3502 45.9204C78.0253 45.9204 84.2705 52.1656 84.2705 59.8408C84.2705 67.516 78.0253 73.7613 70.3502 73.7613ZM100.322 86.6639L84.8051 71.1465C87.2528 68.0238 88.725 64.106 88.725 59.8408C88.725 49.709 80.482 41.4658 70.3502 41.4658C60.2185 41.4658 51.9754 49.709 51.9754 59.8408C51.9754 69.9727 60.2185 78.2158 70.3502 78.2158C74.6154 78.2158 78.5354 76.7414 81.6557 74.2958L97.173 89.8132C97.6073 90.2476 98.1775 90.4658 98.7476 90.4658C99.3178 90.4658 99.888 90.2476 100.322 89.8132C101.193 88.9424 101.193 87.5347 100.322 86.6639Z" fill="url(#paint0_linear_4224_71674)"/> +<path d="M157.809 77.9766C157.816 77.5423 157.667 77.1198 157.39 76.7854C157.113 76.4509 156.725 76.2267 156.297 76.153C152.758 75.5333 142.84 73.1814 139.486 69.8275C136.131 66.4713 133.798 56.5153 133.158 52.9786C133.084 52.5508 132.86 52.1634 132.526 51.8862C132.192 51.6089 131.77 51.4599 131.336 51.466V77.9766H157.809Z" fill="#AB71FF"/> +<path d="M131.449 51.4689C131.011 51.4628 130.585 51.6144 130.25 51.8959C129.914 52.1775 129.691 52.5703 129.622 53.0027C129.038 56.5439 126.829 66.4753 123.489 69.8159C120.146 73.1565 110.142 75.5117 106.492 76.1872C106.063 76.2612 105.675 76.4857 105.397 76.8204C105.119 77.1551 104.97 77.578 104.976 78.013H131.449V51.4689Z" fill="#7542E5"/> +<path d="M131.372 77.9379V104.375C131.807 104.381 132.229 104.232 132.563 103.955C132.897 103.677 133.121 103.289 133.195 102.861C133.816 99.2152 136.186 89.2247 139.521 85.8686C142.859 82.5146 152.777 80.3087 156.315 79.7425C157.206 79.5976 157.857 78.8218 157.845 77.9189H131.372V77.9379Z" fill="#7542E5"/> +<path d="M104.976 77.9346C104.97 78.3695 105.119 78.7924 105.397 79.1271C105.675 79.4618 106.063 79.6863 106.492 79.7603C110.142 80.3266 120.128 82.5536 123.489 85.8942C126.847 89.2348 128.964 99.2944 129.621 102.889C129.691 103.322 129.914 103.715 130.249 103.996C130.585 104.278 131.01 104.43 131.448 104.424V77.9346H104.976Z" fill="#592ACB"/> +<path d="M62.7863 7.25688C62.7861 7.15433 62.7502 7.05505 62.6849 6.97603C62.6195 6.897 62.5288 6.84313 62.4281 6.82364C61.5726 6.67491 59.1962 6.11558 58.3931 5.31312C57.5907 4.51065 57.0313 2.14528 56.8839 1.27945C56.8641 1.17881 56.8102 1.08808 56.7313 1.02258C56.6523 0.957082 56.5532 0.920822 56.4507 0.919922V7.25688H62.7863Z" fill="#AB71FF"/> +<path d="M56.4841 0.919922C56.3794 0.921037 56.2785 0.958884 56.199 1.02685C56.1194 1.09481 56.0663 1.18857 56.0489 1.29173C55.9105 2.14076 55.3796 4.47508 54.5733 5.33575C53.7663 6.19576 51.3893 6.70466 50.4762 6.85468C50.3748 6.87391 50.2832 6.92787 50.2173 7.00729C50.1513 7.08671 50.1151 7.18662 50.1148 7.28986H56.4841V0.919922Z" fill="#9158FF"/> +<path d="M56.4107 7.25781V13.6265C56.5138 13.6258 56.6135 13.5896 56.693 13.5238C56.7724 13.4581 56.8267 13.3668 56.8465 13.2656C56.9946 12.3959 57.5565 9.9743 58.3635 9.1686C59.1711 8.36096 61.5488 7.83008 62.4088 7.69235C62.5119 7.67516 62.6056 7.62216 62.6735 7.54268C62.7414 7.46321 62.7791 7.36233 62.78 7.25781H56.4107Z" fill="#7542E5"/> +<path d="M50.1141 7.25781C50.1131 7.36286 50.1503 7.46469 50.2187 7.54446C50.287 7.62424 50.382 7.67654 50.4859 7.6917C51.3459 7.83008 53.7656 8.36096 54.5726 9.16795C55.3789 9.9743 55.9098 12.3526 56.0476 13.254C56.0799 13.47 56.2655 13.6284 56.4834 13.6258V7.25781H50.1141Z" fill="#592ACB"/> +<path d="M49.8614 99.9389C49.861 99.7694 49.8018 99.6053 49.6938 99.4747C49.5858 99.3441 49.4358 99.2551 49.2694 99.2229C47.8555 98.9771 43.9281 98.0527 42.6008 96.7264C41.2746 95.4002 40.3502 91.491 40.1065 90.06C40.0738 89.8937 39.9847 89.7437 39.8543 89.6355C39.7238 89.5272 39.56 89.4673 39.3905 89.4658V99.9389H49.8614Z" fill="#AB71FF"/> +<path d="M39.4457 89.4658C39.2728 89.4677 39.106 89.5302 38.9746 89.6425C38.8431 89.7549 38.7553 89.9098 38.7265 90.0803C38.4978 91.4835 37.6204 95.3414 36.2878 96.7638C34.9541 98.1852 31.0256 99.0262 29.5166 99.2741C29.349 99.3059 29.1977 99.3951 29.0886 99.5264C28.9796 99.6576 28.9197 99.8227 28.9192 99.9934H39.4457V89.4658Z" fill="#9158FF"/> +<path d="M39.3244 99.9404V110.466C39.4949 110.465 39.6597 110.405 39.791 110.296C39.9223 110.188 40.0119 110.037 40.0447 109.87C40.2894 108.432 41.2181 104.43 42.5518 103.098C43.8866 101.764 47.8162 100.886 49.2375 100.659C49.4079 100.63 49.5628 100.543 49.675 100.411C49.7872 100.28 49.8495 100.113 49.8509 99.9404H39.3244Z" fill="#7542E5"/> +<path d="M28.9181 99.9404C28.9165 100.114 28.9779 100.282 29.0909 100.414C29.2039 100.546 29.3608 100.632 29.5326 100.658C30.9539 100.886 34.9529 101.764 36.2866 103.097C37.6193 104.43 38.4967 108.361 38.7243 109.85C38.7777 110.207 39.0844 110.469 39.4446 110.465V99.9404H28.9181Z" fill="#592ACB"/> +<path d="M29.8933 41.3729C29.896 41.1298 29.8106 40.894 29.6529 40.709C29.4951 40.524 29.2758 40.4024 29.0354 40.3666C27.0422 40.0118 21.4518 38.6895 19.5705 36.7887C17.6891 34.888 16.3475 29.3341 15.9926 27.3238C15.957 27.0833 15.8355 26.8638 15.6505 26.7061C15.4654 26.5483 15.2295 26.463 14.9864 26.4659V41.3729H29.8933Z" fill="#AB71FF"/> +<path d="M15.0596 26.4668C14.8179 26.468 14.5843 26.5548 14.4005 26.7119C14.2167 26.869 14.0945 27.0861 14.0556 27.3248C13.7179 29.3179 12.4709 34.9072 10.5885 36.7896C8.70712 38.671 3.02431 40.0127 1.0129 40.3675C0.772374 40.4031 0.552898 40.5247 0.395144 40.7097C0.23739 40.8947 0.152054 41.1306 0.154948 41.3738H15.0596V26.4668Z" fill="#7542E5"/> +<path d="M14.9864 41.3037V56.2084C15.2294 56.2111 15.4652 56.1256 15.6502 55.9679C15.8352 55.8102 15.9568 55.5909 15.9926 55.3505C16.3475 53.3379 17.6891 47.6562 19.5705 45.7737C21.4518 43.8924 27.024 42.6454 29.0354 42.3077C29.2739 42.2686 29.4909 42.1464 29.648 41.9626C29.805 41.7789 29.8919 41.5454 29.8933 41.3037H14.9864Z" fill="#7542E5"/> +<path d="M0.154877 41.3037C0.156279 41.5454 0.243199 41.7789 0.400235 41.9626C0.557272 42.1464 0.774287 42.2686 1.01283 42.3077C3.04249 42.6454 8.70705 43.8924 10.5884 45.7737C12.4709 47.6562 13.7179 53.3197 14.0556 55.3505C14.1354 55.8445 14.561 56.205 15.0596 56.2084V41.3037H0.154877Z" fill="#592ACB"/> +<defs> +<linearGradient id="paint0_linear_4224_71674" x1="51.9754" y1="41.4658" x2="100.975" y2="41.4658" gradientUnits="userSpaceOnUse"> +<stop stop-color="#9059FF"/> +<stop offset="0.520833" stop-color="#FF4AA2"/> +<stop offset="1" stop-color="#FFBD4F"/> +</linearGradient> +</defs> +</svg> diff --git a/browser/components/urlbar/content/quicksuggestOnboarding_magglass_animation.svg b/browser/components/urlbar/content/quicksuggestOnboarding_magglass_animation.svg new file mode 100644 index 0000000000..e0f9130a3b --- /dev/null +++ b/browser/components/urlbar/content/quicksuggestOnboarding_magglass_animation.svg @@ -0,0 +1,4 @@ +<!-- 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 https://mozilla.org/MPL/2.0/. --> +<svg id="euVYRfkX9lv1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 158 115" shape-rendering="geometricPrecision" text-rendering="geometricPrecision"><style><![CDATA[#euVYRfkX9lv2_ts {animation: euVYRfkX9lv2_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv2_ts__ts { 0% {transform: translate(108.455776px,24.44152px) scale(0.213297,0.213297)} 26.666667% {transform: translate(108.455776px,24.44152px) scale(0.213297,0.213297)} 36.666667% {transform: translate(108.455776px,24.44152px) scale(1.48127,1.48127)} 40% {transform: translate(108.455776px,24.44152px) scale(1,1)} 100% {transform: translate(108.455776px,24.44152px) scale(1,1)}} #euVYRfkX9lv2 {animation: euVYRfkX9lv2_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv2_c_o { 0% {opacity: 0} 26.666667% {opacity: 0} 36.666667% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv7_ts {animation: euVYRfkX9lv7_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv7_ts__ts { 0% {transform: translate(73.398804px,63.9893px) scale(0.388396,0.388396);animation-timing-function: cubic-bezier(0,0,0.13,1.06)} 13.333333% {transform: translate(73.398804px,63.9893px) scale(1,1)} 100% {transform: translate(73.398804px,63.9893px) scale(1,1)}} #euVYRfkX9lv7 {animation: euVYRfkX9lv7_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv7_c_o { 0% {opacity: 0;animation-timing-function: cubic-bezier(0.175,0.885,0.32,1.275)} 13.333333% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv8 {animation: euVYRfkX9lv8_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv8_c_o { 0% {opacity: 0} 10% {opacity: 0;animation-timing-function: cubic-bezier(0,0,0.215,0.965)} 17.333333% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv9_ts {animation: euVYRfkX9lv9_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv9_ts__ts { 0% {transform: translate(124.240119px,82.760579px) scale(0.294172,0.294172)} 20% {transform: translate(124.240119px,82.760579px) scale(0.294172,0.294172)} 26.666667% {transform: translate(124.240119px,82.760579px) scale(0.992195,0.992195)} 30% {transform: translate(124.240119px,82.760579px) scale(0.886381,0.886381)} 100% {transform: translate(124.240119px,82.760579px) scale(0.886381,0.886381)}} #euVYRfkX9lv9 {animation: euVYRfkX9lv9_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv9_c_o { 0% {opacity: 0} 20% {opacity: 0} 26.666667% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv14_ts {animation: euVYRfkX9lv14_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv14_ts__ts { 0% {transform: translate(51.975758px,7.136011px) scale(0.497406,0.497406)} 33.333333% {transform: translate(51.975758px,7.136011px) scale(0.497406,0.497406)} 40% {transform: translate(51.975758px,7.136011px) scale(1.502097,1.502097)} 43.333333% {transform: translate(51.975758px,7.136011px) scale(1,1)} 100% {transform: translate(51.975758px,7.136011px) scale(1,1)}} #euVYRfkX9lv14 {animation: euVYRfkX9lv14_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv14_c_o { 0% {opacity: 0} 33.333333% {opacity: 0} 40% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv19_ts {animation: euVYRfkX9lv19_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv19_ts__ts { 0% {transform: translate(34.889669px,100.965903px) scale(0.298535,0.298535)} 30% {transform: translate(34.889669px,100.965903px) scale(0.298535,0.298535)} 36.666667% {transform: translate(34.889669px,100.965903px) scale(1.70464,1.70464)} 40% {transform: translate(34.889669px,100.965903px) scale(1,1)} 100% {transform: translate(34.889669px,100.965903px) scale(1,1)}} #euVYRfkX9lv19 {animation: euVYRfkX9lv19_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv19_c_o { 0% {opacity: 0} 30% {opacity: 0} 36.666667% {opacity: 1} 100% {opacity: 1}} #euVYRfkX9lv24_ts {animation: euVYRfkX9lv24_ts__ts 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv24_ts__ts { 0% {transform: translate(24.455157px,39.9597px) scale(0.418717,0.418717)} 26.666667% {transform: translate(24.455157px,39.9597px) scale(0.418717,0.418717)} 33.333333% {transform: translate(24.455157px,39.9597px) scale(1,1)} 36.666667% {transform: translate(24.455157px,39.9597px) scale(0.804029,0.804029)} 100% {transform: translate(24.455157px,39.9597px) scale(0.804029,0.804029)}} #euVYRfkX9lv24 {animation: euVYRfkX9lv24_c_o 3000ms linear 1 normal forwards}@keyframes euVYRfkX9lv24_c_o { 0% {opacity: 0} 26.666667% {opacity: 0} 33.333333% {opacity: 1} 100% {opacity: 1}}]]></style><defs><linearGradient id="euVYRfkX9lv8-fill" x1="51.9754" y1="41.4658" x2="100.975" y2="41.4658" spreadMethod="pad" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1 0 0 1 0 0)"><stop id="euVYRfkX9lv8-fill-0" offset="0%" stop-color="rgb(144,89,255)"/><stop id="euVYRfkX9lv8-fill-1" offset="52.0833%" stop-color="rgb(255,74,162)"/><stop id="euVYRfkX9lv8-fill-2" offset="100%" stop-color="rgb(255,189,79)"/></linearGradient></defs><g id="euVYRfkX9lv2_ts" transform="translate(108.455776,24.44152) scale(0.213297,0.213297)"><g id="euVYRfkX9lv2" transform="translate(-105.444679,-24.474966)" opacity="0"><path id="euVYRfkX9lv3" d="M120.886,24.4352C120.885,24.1852,120.798,23.9433,120.639,23.7507C120.479,23.558,120.258,23.4268,120.013,23.3792C117.928,23.0168,112.136,21.6535,110.178,19.6977C108.223,17.7418,106.859,11.9768,106.5,9.8665C106.452,9.62121,106.32,9.40009,106.128,9.24045C105.936,9.0808,105.694,8.99243,105.444,8.99023L105.444,24.4352L120.886,24.4352Z" transform="matrix(1 0 0 1 0.0017 0)" fill="rgb(171,113,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv4" d="M105.525,8.99023C105.27,8.99295,105.025,9.0852,104.831,9.25084C104.637,9.41649,104.507,9.645,104.465,9.89644C104.128,11.9657,102.834,17.6552,100.868,19.7528C98.9015,21.8489,93.1081,23.0893,90.8827,23.4549C90.6355,23.5018,90.4123,23.6333,90.2516,23.8269C90.0908,24.0204,90.0025,24.2639,90.0017,24.5156L105.525,24.5156L105.525,8.99023Z" fill="rgb(145,88,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv5" d="M105.347,24.4375L105.347,39.9597C105.598,39.9582,105.841,39.8698,106.035,39.7095C106.228,39.5492,106.36,39.3269,106.409,39.0802C106.77,36.9605,108.139,31.0583,110.106,29.0946C112.075,27.1262,117.87,25.8323,119.966,25.4966C120.217,25.4547,120.445,25.3255,120.611,25.1318C120.776,24.9381,120.868,24.6922,120.87,24.4375L105.347,24.4375Z" transform="matrix(1 0 0 1 0.0017 0)" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv6" d="M90,24.4375C89.9977,24.6935,90.0883,24.9417,90.2549,25.1362C90.4215,25.3306,90.6529,25.4581,90.9063,25.495C93.0024,25.8323,98.8998,27.1262,100.867,29.093C102.832,31.0583,104.126,36.8549,104.462,39.0519C104.54,39.5783,104.993,39.9644,105.524,39.9581L105.524,24.4375L90,24.4375Z" transform="matrix(1 0 0 1 0.0017 0)" fill="rgb(89,42,203)" stroke="none" stroke-width="1"/></g></g><g id="euVYRfkX9lv7_ts" transform="translate(73.398804,63.9893) scale(0.388396,0.388396)"><circle id="euVYRfkX9lv7" r="50.5" transform="translate(-0.000004,0)" opacity="0" fill="rgb(249,249,251)" stroke="none" stroke-width="1"/></g><path id="euVYRfkX9lv8" d="M70.3502,73.7613C62.6751,73.7613,56.4299,67.516,56.4299,59.8408C56.4299,52.1656,62.6751,45.9204,70.3502,45.9204C78.0253,45.9204,84.2705,52.1656,84.2705,59.8408C84.2705,67.516,78.0253,73.7613,70.3502,73.7613ZM100.322,86.6639L84.8051,71.1465C87.2528,68.0238,88.725,64.106,88.725,59.8408C88.725,49.709,80.482,41.4658,70.3502,41.4658C60.2185,41.4658,51.9754,49.709,51.9754,59.8408C51.9754,69.9727,60.2185,78.2158,70.3502,78.2158C74.6154,78.2158,78.5354,76.7414,81.6557,74.2958L97.173,89.8132C97.6073,90.2476,98.1775,90.4658,98.7476,90.4658C99.3178,90.4658,99.888,90.2476,100.322,89.8132C101.193,88.9424,101.193,87.5347,100.322,86.6639Z" transform="matrix(1.003058 0 0 1.003058 -3.310375 -0.201723)" opacity="0" clip-rule="evenodd" fill="url(#euVYRfkX9lv8-fill)" fill-rule="evenodd" stroke="none" stroke-width="1"/><g id="euVYRfkX9lv9_ts" transform="translate(124.240119,82.760579) scale(0.294172,0.294172)"><g id="euVYRfkX9lv9" transform="translate(-131.449,-77.976597)" opacity="0"><path id="euVYRfkX9lv10" d="M157.809,77.9766C157.816,77.5423,157.667,77.1198,157.39,76.7854C157.113,76.4509,156.725,76.2267,156.297,76.153C152.758,75.5333,142.84,73.1814,139.486,69.8275C136.131,66.4713,133.798,56.5153,133.158,52.9786C133.084,52.5508,132.86,52.1634,132.526,51.8862C132.192,51.6089,131.77,51.4599,131.336,51.466L131.336,77.9766L157.809,77.9766Z" fill="rgb(171,113,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv11" d="M131.449,51.4689C131.011,51.4628,130.585,51.6144,130.25,51.8959C129.914,52.1775,129.691,52.5703,129.622,53.0027C129.038,56.5439,126.829,66.4753,123.489,69.8159C120.146,73.1565,110.142,75.5117,106.492,76.1872C106.063,76.2612,105.675,76.4857,105.397,76.8204C105.119,77.1551,104.97,77.578,104.976,78.013L131.449,78.013L131.449,51.4689Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv12" d="M131.372,77.9379L131.372,104.375C131.807,104.381,132.229,104.232,132.563,103.955C132.897,103.677,133.121,103.289,133.195,102.861C133.816,99.2152,136.186,89.2247,139.521,85.8686C142.859,82.5146,152.777,80.3087,156.315,79.7425C157.206,79.5976,157.857,78.8218,157.845,77.9189L131.372,77.9189L131.372,77.9379Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv13" d="M104.976,77.9346C104.97,78.3695,105.119,78.7924,105.397,79.1271C105.675,79.4618,106.063,79.6863,106.492,79.7603C110.142,80.3266,120.128,82.5536,123.489,85.8942C126.847,89.2348,128.964,99.2944,129.621,102.889C129.691,103.322,129.914,103.715,130.249,103.996C130.585,104.278,131.01,104.43,131.448,104.424L131.448,77.9346L104.976,77.9346Z" fill="rgb(89,42,203)" stroke="none" stroke-width="1"/></g></g><g id="euVYRfkX9lv14_ts" transform="translate(51.975758,7.136011) scale(0.497406,0.497406)"><g id="euVYRfkX9lv14" transform="translate(-56.45055,-7.273211)" opacity="0"><path id="euVYRfkX9lv15" d="M62.7863,7.25688C62.7861,7.15433,62.7502,7.05505,62.6849,6.97603C62.6195,6.897,62.5288,6.84313,62.4281,6.82364C61.5726,6.67491,59.1962,6.11558,58.3931,5.31312C57.5907,4.51065,57.0313,2.14528,56.8839,1.27945C56.8641,1.17881,56.8102,1.08808,56.7313,1.02258C56.6523,0.957082,56.5532,0.920822,56.4507,0.919922L56.4507,7.25688L62.7863,7.25688Z" fill="rgb(171,113,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv16" d="M56.4841,0.919922C56.3794,0.921037,56.2785,0.958884,56.199,1.02685C56.1194,1.09481,56.0663,1.18857,56.0489,1.29173C55.9105,2.14076,55.3796,4.47508,54.5733,5.33575C53.7663,6.19576,51.3893,6.70466,50.4762,6.85468C50.3748,6.87391,50.2832,6.92787,50.2173,7.00729C50.1513,7.08671,50.1151,7.18662,50.1148,7.28986L56.4841,7.28986L56.4841,0.919922Z" fill="rgb(145,88,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv17" d="M56.4107,7.25781L56.4107,13.6265C56.5138,13.6258,56.6135,13.5896,56.693,13.5238C56.7724,13.4581,56.8267,13.3668,56.8465,13.2656C56.9946,12.3959,57.5565,9.9743,58.3635,9.1686C59.1711,8.36096,61.5488,7.83008,62.4088,7.69235C62.5119,7.67516,62.6056,7.62216,62.6735,7.54268C62.7414,7.46321,62.7791,7.36233,62.78,7.25781L56.4107,7.25781Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv18" d="M50.1141,7.25781C50.1131,7.36286,50.1503,7.46469,50.2187,7.54446C50.287,7.62424,50.382,7.67654,50.4859,7.6917C51.3459,7.83008,53.7656,8.36096,54.5726,9.16795C55.3789,9.9743,55.9098,12.3526,56.0476,13.254C56.0799,13.47,56.2655,13.6284,56.4834,13.6258L56.4834,7.25781L50.1141,7.25781Z" fill="rgb(89,42,203)" stroke="none" stroke-width="1"/></g></g><g id="euVYRfkX9lv19_ts" transform="translate(34.889669,100.965903) scale(0.298535,0.298535)"><g id="euVYRfkX9lv19" transform="translate(-39.390301,-99.9659)" opacity="0"><path id="euVYRfkX9lv20" d="M49.8614,99.9389C49.861,99.7694,49.8018,99.6053,49.6938,99.4747C49.5858,99.3441,49.4358,99.2551,49.2694,99.2229C47.8555,98.9771,43.9281,98.0527,42.6008,96.7264C41.2746,95.4002,40.3502,91.491,40.1065,90.06C40.0738,89.8937,39.9847,89.7437,39.8543,89.6355C39.7238,89.5272,39.56,89.4673,39.3905,89.4658L39.3905,99.9389L49.8614,99.9389Z" fill="rgb(171,113,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv21" d="M39.4457,89.4658C39.2728,89.4677,39.106,89.5302,38.9746,89.6425C38.8431,89.7549,38.7553,89.9098,38.7265,90.0803C38.4978,91.4835,37.6204,95.3414,36.2878,96.7638C34.9541,98.1852,31.0256,99.0262,29.5166,99.2741C29.349,99.3059,29.1977,99.3951,29.0886,99.5264C28.9796,99.6576,28.9197,99.8227,28.9192,99.9934L39.4457,99.9934L39.4457,89.4658Z" fill="rgb(145,88,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv22" d="M39.3244,99.9404L39.3244,110.466C39.4949,110.465,39.6597,110.405,39.791,110.296C39.9223,110.188,40.0119,110.037,40.0447,109.87C40.2894,108.432,41.2181,104.43,42.5518,103.098C43.8866,101.764,47.8162,100.886,49.2375,100.659C49.4079,100.63,49.5628,100.543,49.675,100.411C49.7872,100.28,49.8495,100.113,49.8509,99.9404L39.3244,99.9404Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv23" d="M28.9181,99.9404C28.9165,100.114,28.9779,100.282,29.0909,100.414C29.2039,100.546,29.3608,100.632,29.5326,100.658C30.9539,100.886,34.9529,101.764,36.2866,103.097C37.6193,104.43,38.4967,108.361,38.7243,109.85C38.7777,110.207,39.0844,110.469,39.4446,110.465L39.4446,99.9404L28.9181,99.9404Z" fill="rgb(89,42,203)" stroke="none" stroke-width="1"/></g></g><g id="euVYRfkX9lv24_ts" transform="translate(24.455157,39.9597) scale(0.418717,0.418717)"><g id="euVYRfkX9lv24" transform="translate(-15.024119,-41.337145)" opacity="0"><path id="euVYRfkX9lv25" d="M29.8933,41.3729C29.896,41.1298,29.8106,40.894,29.6529,40.709C29.4951,40.524,29.2758,40.4024,29.0354,40.3666C27.0422,40.0118,21.4518,38.6895,19.5705,36.7887C17.6891,34.888,16.3475,29.3341,15.9926,27.3238C15.957,27.0833,15.8355,26.8638,15.6505,26.7061C15.4654,26.5483,15.2295,26.463,14.9864,26.4659L14.9864,41.3729L29.8933,41.3729Z" fill="rgb(171,113,255)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv26" d="M15.0596,26.4668C14.8179,26.468,14.5843,26.5548,14.4005,26.7119C14.2167,26.869,14.0945,27.0861,14.0556,27.3248C13.7179,29.3179,12.4709,34.9072,10.5885,36.7896C8.70712,38.671,3.02431,40.0127,1.0129,40.3675C0.772374,40.4031,0.552898,40.5247,0.395144,40.7097C0.23739,40.8947,0.152054,41.1306,0.154948,41.3738L15.0596,41.3738L15.0596,26.4668Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv27" d="M14.9864,41.3037L14.9864,56.2084C15.2294,56.2111,15.4652,56.1256,15.6502,55.9679C15.8352,55.8102,15.9568,55.5909,15.9926,55.3505C16.3475,53.3379,17.6891,47.6562,19.5705,45.7737C21.4518,43.8924,27.024,42.6454,29.0354,42.3077C29.2739,42.2686,29.4909,42.1464,29.648,41.9626C29.805,41.7789,29.8919,41.5454,29.8933,41.3037L14.9864,41.3037Z" fill="rgb(117,66,229)" stroke="none" stroke-width="1"/><path id="euVYRfkX9lv28" d="M0.154877,41.3037C0.156279,41.5454,0.243199,41.7789,0.400235,41.9626C0.557272,42.1464,0.774287,42.2686,1.01283,42.3077C3.04249,42.6454,8.70705,43.8924,10.5884,45.7737C12.4709,47.6562,13.7179,53.3197,14.0556,55.3505C14.1354,55.8445,14.561,56.205,15.0596,56.2084L15.0596,41.3037L0.154877,41.3037Z" fill="rgb(89,42,203)" stroke="none" stroke-width="1"/></g></g></svg> diff --git a/browser/components/urlbar/content/stripOnShare.ftl b/browser/components/urlbar/content/stripOnShare.ftl new file mode 100644 index 0000000000..3a9b5678ea --- /dev/null +++ b/browser/components/urlbar/content/stripOnShare.ftl @@ -0,0 +1,18 @@ +# 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/. + +### These strings are related to the Firefox Strip on Share Feature. +### Strip on Share is closely related to QueryStringStripping and strips +### query strings when copying from the url bar or when copying in-content links. + +## These strings are used in the context menu of the url bar +## and the in-content context menu. + +main-context-menu-strip-on-share-link = + .label = Copy Clean Link + .accesskey = y + +text-action-strip-on-share = + .label = Copy Clean + .accesskey = n diff --git a/browser/components/urlbar/content/suggest-example.svg b/browser/components/urlbar/content/suggest-example.svg new file mode 100644 index 0000000000..7557d8ef82 --- /dev/null +++ b/browser/components/urlbar/content/suggest-example.svg @@ -0,0 +1,53 @@ +<!-- 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 https://mozilla.org/MPL/2.0/. --> +<svg width="637" height="304" viewBox="0 0 637 304" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g filter="url(#filter0_d_7341_14209)"> +<g clip-path="url(#clip0_7341_14209)"> +<rect x="14" y="12" width="609" height="276" rx="4" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.9225 38.7794L47.7731 43.63C48.0794 43.9472 48.0751 44.4514 47.7632 44.7632C47.4514 45.0751 46.9472 45.0794 46.63 44.7731L41.7794 39.9225C39.2331 41.8671 35.6153 41.5044 33.5056 39.0932C31.396 36.6819 31.517 33.048 33.7825 30.7825C36.048 28.517 39.6819 28.396 42.0932 30.5056C44.5044 32.6153 44.8671 36.2331 42.9225 38.7794ZM33.6605 35.1068C33.6605 37.5624 35.6511 39.5531 38.1068 39.5531C40.5613 39.5505 42.5504 37.5613 42.5531 35.1068C42.5531 32.6512 40.5624 30.6605 38.1068 30.6605C35.6511 30.6605 33.6605 32.6512 33.6605 35.1068Z" fill="black"/> +<path d="M56.9972 42H58.0866V37.5682C58.0866 36.5295 58.8438 35.7955 59.6932 35.7955C60.5195 35.7955 61.0966 36.3356 61.0966 37.1435V42H62.2045V37.3835C62.2045 36.4695 62.777 35.7955 63.7741 35.7955C64.5497 35.7955 65.2145 36.2063 65.2145 37.2543V42H66.304V37.2543C66.304 35.5877 65.4084 34.8168 64.1435 34.8168C63.1278 34.8168 62.3846 35.283 62.0199 36.017H61.946C61.5952 35.2599 60.9766 34.8168 60.044 34.8168C59.1207 34.8168 58.4375 35.2599 58.142 36.017H58.0497V34.9091H56.9972V42ZM71.1767 42.1477C73.0971 42.1477 74.3897 40.6889 74.3897 38.4915C74.3897 36.2756 73.0971 34.8168 71.1767 34.8168C69.2562 34.8168 67.9636 36.2756 67.9636 38.4915C67.9636 40.6889 69.2562 42.1477 71.1767 42.1477ZM71.1767 41.169C69.7179 41.169 69.0531 39.9134 69.0531 38.4915C69.0531 37.0696 69.7179 35.7955 71.1767 35.7955C72.6355 35.7955 73.3002 37.0696 73.3002 38.4915C73.3002 39.9134 72.6355 41.169 71.1767 41.169ZM80.5216 39.1009C80.5216 40.4304 79.5059 41.0398 78.6934 41.0398C77.7886 41.0398 77.1423 40.375 77.1423 39.3409V34.9091H76.0528V39.4148C76.0528 41.2244 77.0131 42.0923 78.3426 42.0923C79.4136 42.0923 80.1153 41.5199 80.4477 40.7997H80.5216V42H81.6111V34.9091H80.5216V39.1009ZM84.696 37.7344C84.696 36.4972 85.4624 35.7955 86.5057 35.7955C87.5167 35.7955 88.1307 36.4556 88.1307 37.5682V42H89.2202V37.4943C89.2202 35.6847 88.2553 34.8168 86.8196 34.8168C85.7486 34.8168 85.0838 35.2969 84.7514 36.017H84.6591V34.9091H83.6065V42H84.696V37.7344ZM94.2948 34.9091H92.7806V33.2102H91.6911V34.9091H90.6201V35.8324H91.6911V40.2642C91.6911 41.5014 92.6883 42.0923 93.6116 42.0923C94.0178 42.0923 94.2764 42.0185 94.4241 41.9631L94.2025 40.9844C94.1102 41.0028 93.9624 41.0398 93.7224 41.0398C93.2423 41.0398 92.7806 40.892 92.7806 39.9688V35.8324H94.2948V34.9091ZM102.563 42.1477C104.003 42.1477 105.056 41.4276 105.388 40.3565L104.336 40.0611C104.059 40.7997 103.417 41.169 102.563 41.169C101.284 41.169 100.402 40.3427 100.352 38.8239H105.499V38.3622C105.499 35.7216 103.929 34.8168 102.452 34.8168C100.532 34.8168 99.2575 36.331 99.2575 38.5099C99.2575 40.6889 100.513 42.1477 102.563 42.1477ZM100.352 37.8821C100.426 36.7788 101.206 35.7955 102.452 35.7955C103.634 35.7955 104.391 36.6818 104.391 37.8821H100.352ZM112.739 34.9091H111.557L109.6 40.5597H109.526L107.568 34.9091H106.387L109.009 42H110.117L112.739 34.9091ZM116.896 42.1477C118.336 42.1477 119.389 41.4276 119.721 40.3565L118.669 40.0611C118.392 40.7997 117.75 41.169 116.896 41.169C115.617 41.169 114.735 40.3427 114.685 38.8239H119.832V38.3622C119.832 35.7216 118.262 34.8168 116.785 34.8168C114.865 34.8168 113.591 36.331 113.591 38.5099C113.591 40.6889 114.846 42.1477 116.896 42.1477ZM114.685 37.8821C114.759 36.7788 115.539 35.7955 116.785 35.7955C117.967 35.7955 118.724 36.6818 118.724 37.8821H114.685ZM121.489 42H122.579V37.5128C122.579 36.5526 123.336 35.8509 124.37 35.8509C124.661 35.8509 124.961 35.9062 125.035 35.9247V34.8168C124.91 34.8075 124.624 34.7983 124.462 34.7983C123.613 34.7983 122.874 35.2784 122.616 35.9801H122.542V34.9091H121.489V42ZM129.083 42.1477C130.524 42.1477 131.576 41.4276 131.909 40.3565L130.856 40.0611C130.579 40.7997 129.938 41.169 129.083 41.169C127.805 41.169 126.923 40.3427 126.872 38.8239H132.02V38.3622C132.02 35.7216 130.45 34.8168 128.973 34.8168C127.052 34.8168 125.778 36.331 125.778 38.5099C125.778 40.6889 127.034 42.1477 129.083 42.1477ZM126.872 37.8821C126.946 36.7788 127.726 35.7955 128.973 35.7955C130.154 35.7955 130.912 36.6818 130.912 37.8821H126.872ZM138.7 36.4972C138.358 35.4908 137.592 34.8168 136.188 34.8168C134.692 34.8168 133.585 35.6662 133.585 36.8665C133.585 37.8452 134.166 38.5007 135.468 38.8054L136.65 39.0824C137.365 39.2486 137.702 39.5902 137.702 40.0795C137.702 40.6889 137.056 41.1875 136.04 41.1875C135.15 41.1875 134.591 40.8043 134.397 40.0426L133.363 40.3011C133.617 41.506 134.609 42.1477 136.059 42.1477C137.707 42.1477 138.829 41.2475 138.829 40.0241C138.829 39.0362 138.21 38.413 136.945 38.1037L135.893 37.8452C135.053 37.6374 134.674 37.3558 134.674 36.8111C134.674 36.2017 135.32 35.7585 136.188 35.7585C137.139 35.7585 137.532 36.2848 137.721 36.7741L138.7 36.4972ZM143.553 34.9091H142.038V33.2102H140.949V34.9091H139.878V35.8324H140.949V40.2642C140.949 41.5014 141.946 42.0923 142.869 42.0923C143.276 42.0923 143.534 42.0185 143.682 41.9631L143.46 40.9844C143.368 41.0028 143.22 41.0398 142.98 41.0398C142.5 41.0398 142.038 40.892 142.038 39.9688V35.8324H143.553V34.9091Z" fill="#15141A"/> +<path d="M599.332 37H608.665" stroke="black" stroke-opacity="0.8" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M604 32.3333L608.667 36.9999L604 41.6666" stroke="black" stroke-opacity="0.8" stroke-linecap="round" stroke-linejoin="round"/> +<rect x="23" y="59.5" width="588" height="1" fill="#F0F0F4"/> +<path d="M55.9972 87H57.0866V82.5682C57.0866 81.5295 57.8438 80.7955 58.6932 80.7955C59.5195 80.7955 60.0966 81.3356 60.0966 82.1435V87H61.2045V82.3835C61.2045 81.4695 61.777 80.7955 62.7741 80.7955C63.5497 80.7955 64.2145 81.2063 64.2145 82.2543V87H65.304V82.2543C65.304 80.5877 64.4084 79.8168 63.1435 79.8168C62.1278 79.8168 61.3846 80.283 61.0199 81.017H60.946C60.5952 80.2599 59.9766 79.8168 59.044 79.8168C58.1207 79.8168 57.4375 80.2599 57.142 81.017H57.0497V79.9091H55.9972V87ZM70.1767 87.1477C72.0971 87.1477 73.3897 85.6889 73.3897 83.4915C73.3897 81.2756 72.0971 79.8168 70.1767 79.8168C68.2562 79.8168 66.9636 81.2756 66.9636 83.4915C66.9636 85.6889 68.2562 87.1477 70.1767 87.1477ZM70.1767 86.169C68.7179 86.169 68.0531 84.9134 68.0531 83.4915C68.0531 82.0696 68.7179 80.7955 70.1767 80.7955C71.6355 80.7955 72.3002 82.0696 72.3002 83.4915C72.3002 84.9134 71.6355 86.169 70.1767 86.169ZM79.5216 84.1009C79.5216 85.4304 78.5059 86.0398 77.6934 86.0398C76.7886 86.0398 76.1423 85.375 76.1423 84.3409V79.9091H75.0528V84.4148C75.0528 86.2244 76.0131 87.0923 77.3426 87.0923C78.4136 87.0923 79.1153 86.5199 79.4477 85.7997H79.5216V87H80.6111V79.9091H79.5216V84.1009ZM83.696 82.7344C83.696 81.4972 84.4624 80.7955 85.5057 80.7955C86.5167 80.7955 87.1307 81.4556 87.1307 82.5682V87H88.2202V82.4943C88.2202 80.6847 87.2553 79.8168 85.8196 79.8168C84.7486 79.8168 84.0838 80.2969 83.7514 81.017H83.6591V79.9091H82.6065V87H83.696V82.7344ZM93.2948 79.9091H91.7806V78.2102H90.6911V79.9091H89.6201V80.8324H90.6911V85.2642C90.6911 86.5014 91.6883 87.0923 92.6116 87.0923C93.0178 87.0923 93.2764 87.0185 93.4241 86.9631L93.2025 85.9844C93.1102 86.0028 92.9624 86.0398 92.7224 86.0398C92.2423 86.0398 91.7806 85.892 91.7806 84.9688V80.8324H93.2948V79.9091ZM101.563 87.1477C103.003 87.1477 104.056 86.4276 104.388 85.3565L103.336 85.0611C103.059 85.7997 102.417 86.169 101.563 86.169C100.284 86.169 99.4024 85.3427 99.3517 83.8239H104.499V83.3622C104.499 80.7216 102.929 79.8168 101.452 79.8168C99.5317 79.8168 98.2575 81.331 98.2575 83.5099C98.2575 85.6889 99.5132 87.1477 101.563 87.1477ZM99.3517 82.8821C99.4255 81.7788 100.206 80.7955 101.452 80.7955C102.634 80.7955 103.391 81.6818 103.391 82.8821H99.3517ZM111.739 79.9091H110.557L108.6 85.5597H108.526L106.568 79.9091H105.387L108.009 87H109.117L111.739 79.9091ZM115.896 87.1477C117.336 87.1477 118.389 86.4276 118.721 85.3565L117.669 85.0611C117.392 85.7997 116.75 86.169 115.896 86.169C114.617 86.169 113.735 85.3427 113.685 83.8239H118.832V83.3622C118.832 80.7216 117.262 79.8168 115.785 79.8168C113.865 79.8168 112.591 81.331 112.591 83.5099C112.591 85.6889 113.846 87.1477 115.896 87.1477ZM113.685 82.8821C113.759 81.7788 114.539 80.7955 115.785 80.7955C116.967 80.7955 117.724 81.6818 117.724 82.8821H113.685ZM120.489 87H121.579V82.5128C121.579 81.5526 122.336 80.8509 123.37 80.8509C123.661 80.8509 123.961 80.9062 124.035 80.9247V79.8168C123.91 79.8075 123.624 79.7983 123.462 79.7983C122.613 79.7983 121.874 80.2784 121.616 80.9801H121.542V79.9091H120.489V87ZM128.083 87.1477C129.524 87.1477 130.576 86.4276 130.909 85.3565L129.856 85.0611C129.579 85.7997 128.938 86.169 128.083 86.169C126.805 86.169 125.923 85.3427 125.872 83.8239H131.02V83.3622C131.02 80.7216 129.45 79.8168 127.973 79.8168C126.052 79.8168 124.778 81.331 124.778 83.5099C124.778 85.6889 126.034 87.1477 128.083 87.1477ZM125.872 82.8821C125.946 81.7788 126.726 80.7955 127.973 80.7955C129.154 80.7955 129.912 81.6818 129.912 82.8821H125.872ZM137.7 81.4972C137.358 80.4908 136.592 79.8168 135.188 79.8168C133.692 79.8168 132.585 80.6662 132.585 81.8665C132.585 82.8452 133.166 83.5007 134.468 83.8054L135.65 84.0824C136.365 84.2486 136.702 84.5902 136.702 85.0795C136.702 85.6889 136.056 86.1875 135.04 86.1875C134.15 86.1875 133.591 85.8043 133.397 85.0426L132.363 85.3011C132.617 86.506 133.609 87.1477 135.059 87.1477C136.707 87.1477 137.829 86.2475 137.829 85.0241C137.829 84.0362 137.21 83.413 135.945 83.1037L134.893 82.8452C134.053 82.6374 133.674 82.3558 133.674 81.8111C133.674 81.2017 134.32 80.7585 135.188 80.7585C136.139 80.7585 136.532 81.2848 136.721 81.7741L137.7 81.4972ZM142.553 79.9091H141.038V78.2102H139.949V79.9091H138.878V80.8324H139.949V85.2642C139.949 86.5014 140.946 87.0923 141.869 87.0923C142.276 87.0923 142.534 87.0185 142.682 86.9631L142.46 85.9844C142.368 86.0028 142.22 86.0398 141.98 86.0398C141.5 86.0398 141.038 85.892 141.038 84.9688V80.8324H142.553V79.9091ZM150.802 87.1385C152.584 87.1385 153.72 86.0813 153.84 84.5763H152.243C152.1 85.3381 151.55 85.7766 150.816 85.7766C149.773 85.7766 149.099 84.9041 149.099 83.4545C149.099 82.0234 149.787 81.1648 150.816 81.1648C151.619 81.1648 152.113 81.6818 152.243 82.3651H153.84C153.725 80.8278 152.524 79.8168 150.793 79.8168C148.716 79.8168 147.405 81.3171 147.405 83.4822C147.405 85.6289 148.683 87.1385 150.802 87.1385ZM159.732 84.0178C159.732 85.098 158.961 85.6335 158.222 85.6335C157.419 85.6335 156.883 85.0657 156.883 84.1655V79.9091H155.212V84.424C155.212 86.1275 156.182 87.0923 157.576 87.0923C158.638 87.0923 159.385 86.5337 159.709 85.7397H159.782V87H161.403V79.9091H159.732V84.0178ZM163.121 87H164.793V79.9091H163.121V87ZM163.962 78.9027C164.492 78.9027 164.926 78.4964 164.926 77.9979C164.926 77.4947 164.492 77.0884 163.962 77.0884C163.426 77.0884 162.992 77.4947 162.992 77.9979C162.992 78.4964 163.426 78.9027 163.962 78.9027ZM172.115 81.7834C171.885 80.5831 170.924 79.8168 169.262 79.8168C167.554 79.8168 166.391 80.657 166.396 81.968C166.391 83.0021 167.028 83.6854 168.39 83.967L169.599 84.2209C170.25 84.364 170.555 84.6271 170.555 85.0288C170.555 85.5135 170.029 85.8782 169.235 85.8782C168.468 85.8782 167.97 85.5458 167.827 84.9087L166.197 85.0657C166.405 86.3675 167.499 87.1385 169.239 87.1385C171.012 87.1385 172.263 86.2198 172.268 84.8764C172.263 83.8654 171.612 83.2468 170.273 82.956L169.064 82.6974C168.344 82.5359 168.058 82.2866 168.062 81.8757C168.058 81.3956 168.588 81.0632 169.286 81.0632C170.056 81.0632 170.463 81.4833 170.592 81.9496L172.115 81.7834ZM173.646 87H175.317V79.9091H173.646V87ZM174.486 78.9027C175.017 78.9027 175.451 78.4964 175.451 77.9979C175.451 77.4947 175.017 77.0884 174.486 77.0884C173.95 77.0884 173.517 77.4947 173.517 77.9979C173.517 78.4964 173.95 78.9027 174.486 78.9027ZM178.707 82.8452C178.707 81.8203 179.325 81.2294 180.207 81.2294C181.07 81.2294 181.587 81.7972 181.587 82.7436V87H183.258V82.4851C183.263 80.7862 182.294 79.8168 180.83 79.8168C179.768 79.8168 179.039 80.3246 178.716 81.114H178.633V79.9091H177.035V87H178.707V82.8452ZM188.097 87.1385C189.749 87.1385 190.885 86.3306 191.18 85.098L189.62 84.9226C189.394 85.5227 188.84 85.8366 188.12 85.8366C187.039 85.8366 186.324 85.1257 186.31 83.9116H191.25V83.3991C191.25 80.9109 189.754 79.8168 188.009 79.8168C185.978 79.8168 184.653 81.3079 184.653 83.4961C184.653 85.7212 185.959 87.1385 188.097 87.1385ZM186.315 82.7852C186.365 81.8803 187.035 81.1186 188.032 81.1186C188.992 81.1186 189.638 81.8203 189.648 82.7852H186.315Z" fill="#7A7A8B"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40.9225 84.7794L45.7731 89.63C46.0794 89.9472 46.0751 90.4514 45.7632 90.7632C45.4514 91.0751 44.9472 91.0794 44.63 90.7731L39.7794 85.9225C37.2331 87.867 33.6153 87.5044 31.5056 85.0932C29.396 82.6819 29.517 79.048 31.7825 76.7825C34.048 74.517 37.6819 74.396 40.0932 76.5056C42.5044 78.6153 42.867 82.2331 40.9225 84.7794ZM31.6605 81.1068C31.6605 83.5624 33.6511 85.5531 36.1068 85.5531C38.5613 85.5505 40.5504 83.5613 40.5531 81.1068C40.5531 78.6512 38.5624 76.6605 36.1068 76.6605C33.6511 76.6605 31.6605 78.6512 31.6605 81.1068Z" fill="#7A7A8B"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M41.9225 120.17L46.7731 125.021C47.0794 125.338 47.0751 125.842 46.7632 126.154C46.4514 126.466 45.9472 126.47 45.63 126.164L40.7794 121.313C38.2331 123.258 34.6153 122.895 32.5056 120.484C30.396 118.073 30.517 114.439 32.7825 112.173C35.048 109.908 38.6819 109.787 41.0932 111.896C43.5044 114.006 43.867 117.624 41.9225 120.17ZM32.6605 116.497C32.6605 118.953 34.6511 120.944 37.1068 120.944C39.5613 120.941 41.5504 118.952 41.5531 116.497C41.5531 114.042 39.5624 112.051 37.1068 112.051C34.6511 112.051 32.6605 114.042 32.6605 116.497Z" fill="#7A7A8B"/> +<path d="M55.9972 123H57.0866V118.568C57.0866 117.529 57.8438 116.795 58.6932 116.795C59.5195 116.795 60.0966 117.336 60.0966 118.143V123H61.2045V118.384C61.2045 117.469 61.777 116.795 62.7741 116.795C63.5497 116.795 64.2145 117.206 64.2145 118.254V123H65.304V118.254C65.304 116.588 64.4084 115.817 63.1435 115.817C62.1278 115.817 61.3846 116.283 61.0199 117.017H60.946C60.5952 116.26 59.9766 115.817 59.044 115.817C58.1207 115.817 57.4375 116.26 57.142 117.017H57.0497V115.909H55.9972V123ZM70.1767 123.148C72.0971 123.148 73.3897 121.689 73.3897 119.491C73.3897 117.276 72.0971 115.817 70.1767 115.817C68.2562 115.817 66.9636 117.276 66.9636 119.491C66.9636 121.689 68.2562 123.148 70.1767 123.148ZM70.1767 122.169C68.7179 122.169 68.0531 120.913 68.0531 119.491C68.0531 118.07 68.7179 116.795 70.1767 116.795C71.6355 116.795 72.3002 118.07 72.3002 119.491C72.3002 120.913 71.6355 122.169 70.1767 122.169ZM79.5216 120.101C79.5216 121.43 78.5059 122.04 77.6934 122.04C76.7886 122.04 76.1423 121.375 76.1423 120.341V115.909H75.0528V120.415C75.0528 122.224 76.0131 123.092 77.3426 123.092C78.4136 123.092 79.1153 122.52 79.4477 121.8H79.5216V123H80.6111V115.909H79.5216V120.101ZM83.696 118.734C83.696 117.497 84.4624 116.795 85.5057 116.795C86.5167 116.795 87.1307 117.456 87.1307 118.568V123H88.2202V118.494C88.2202 116.685 87.2553 115.817 85.8196 115.817C84.7486 115.817 84.0838 116.297 83.7514 117.017H83.6591V115.909H82.6065V123H83.696V118.734ZM93.2948 115.909H91.7806V114.21H90.6911V115.909H89.6201V116.832H90.6911V121.264C90.6911 122.501 91.6883 123.092 92.6116 123.092C93.0178 123.092 93.2764 123.018 93.4241 122.963L93.2025 121.984C93.1102 122.003 92.9624 122.04 92.7224 122.04C92.2423 122.04 91.7806 121.892 91.7806 120.969V116.832H93.2948V115.909ZM101.563 123.148C103.003 123.148 104.056 122.428 104.388 121.357L103.336 121.061C103.059 121.8 102.417 122.169 101.563 122.169C100.284 122.169 99.4024 121.343 99.3517 119.824H104.499V119.362C104.499 116.722 102.929 115.817 101.452 115.817C99.5317 115.817 98.2575 117.331 98.2575 119.51C98.2575 121.689 99.5132 123.148 101.563 123.148ZM99.3517 118.882C99.4255 117.779 100.206 116.795 101.452 116.795C102.634 116.795 103.391 117.682 103.391 118.882H99.3517ZM111.739 115.909H110.557L108.6 121.56H108.526L106.568 115.909H105.387L108.009 123H109.117L111.739 115.909ZM115.896 123.148C117.336 123.148 118.389 122.428 118.721 121.357L117.669 121.061C117.392 121.8 116.75 122.169 115.896 122.169C114.617 122.169 113.735 121.343 113.685 119.824H118.832V119.362C118.832 116.722 117.262 115.817 115.785 115.817C113.865 115.817 112.591 117.331 112.591 119.51C112.591 121.689 113.846 123.148 115.896 123.148ZM113.685 118.882C113.759 117.779 114.539 116.795 115.785 116.795C116.967 116.795 117.724 117.682 117.724 118.882H113.685ZM120.489 123H121.579V118.513C121.579 117.553 122.336 116.851 123.37 116.851C123.661 116.851 123.961 116.906 124.035 116.925V115.817C123.91 115.808 123.624 115.798 123.462 115.798C122.613 115.798 121.874 116.278 121.616 116.98H121.542V115.909H120.489V123ZM128.083 123.148C129.524 123.148 130.576 122.428 130.909 121.357L129.856 121.061C129.579 121.8 128.938 122.169 128.083 122.169C126.805 122.169 125.923 121.343 125.872 119.824H131.02V119.362C131.02 116.722 129.45 115.817 127.973 115.817C126.052 115.817 124.778 117.331 124.778 119.51C124.778 121.689 126.034 123.148 128.083 123.148ZM125.872 118.882C125.946 117.779 126.726 116.795 127.973 116.795C129.154 116.795 129.912 117.682 129.912 118.882H125.872ZM137.7 117.497C137.358 116.491 136.592 115.817 135.188 115.817C133.692 115.817 132.585 116.666 132.585 117.866C132.585 118.845 133.166 119.501 134.468 119.805L135.65 120.082C136.365 120.249 136.702 120.59 136.702 121.08C136.702 121.689 136.056 122.188 135.04 122.188C134.15 122.188 133.591 121.804 133.397 121.043L132.363 121.301C132.617 122.506 133.609 123.148 135.059 123.148C136.707 123.148 137.829 122.248 137.829 121.024C137.829 120.036 137.21 119.413 135.945 119.104L134.893 118.845C134.053 118.637 133.674 118.356 133.674 117.811C133.674 117.202 134.32 116.759 135.188 116.759C136.139 116.759 136.532 117.285 136.721 117.774L137.7 117.497ZM142.553 115.909H141.038V114.21H139.949V115.909H138.878V116.832H139.949V121.264C139.949 122.501 140.946 123.092 141.869 123.092C142.276 123.092 142.534 123.018 142.682 122.963L142.46 121.984C142.368 122.003 142.22 122.04 141.98 122.04C141.5 122.04 141.038 121.892 141.038 120.969V116.832H142.553V115.909ZM149.38 113.545H147.709V123H149.38V113.545ZM152.138 125.659C153.439 125.659 154.192 124.99 154.594 123.859L157.433 115.909H155.655L154.017 121.264H153.943L152.308 115.909H150.536L153.107 123.148L152.964 123.531C152.655 124.339 152.165 124.394 151.436 124.182L151.048 125.484C151.274 125.576 151.681 125.659 152.138 125.659ZM158.589 123H160.26V118.831C160.26 117.931 160.939 117.294 161.858 117.294C162.139 117.294 162.49 117.345 162.633 117.391V115.854C162.481 115.826 162.218 115.808 162.033 115.808C161.221 115.808 160.542 116.269 160.283 117.091H160.21V115.909H158.589V123ZM163.743 123H165.415V115.909H163.743V123ZM164.584 114.903C165.115 114.903 165.548 114.496 165.548 113.998C165.548 113.495 165.115 113.088 164.584 113.088C164.048 113.088 163.614 113.495 163.614 113.998C163.614 114.496 164.048 114.903 164.584 114.903ZM170.226 123.138C172.008 123.138 173.144 122.081 173.264 120.576H171.666C171.523 121.338 170.974 121.777 170.24 121.777C169.197 121.777 168.523 120.904 168.523 119.455C168.523 118.023 169.21 117.165 170.24 117.165C171.043 117.165 171.537 117.682 171.666 118.365H173.264C173.148 116.828 171.948 115.817 170.217 115.817C168.139 115.817 166.828 117.317 166.828 119.482C166.828 121.629 168.107 123.138 170.226 123.138ZM180.24 117.783C180.01 116.583 179.049 115.817 177.387 115.817C175.679 115.817 174.516 116.657 174.521 117.968C174.516 119.002 175.153 119.685 176.515 119.967L177.724 120.221C178.375 120.364 178.68 120.627 178.68 121.029C178.68 121.513 178.154 121.878 177.36 121.878C176.593 121.878 176.095 121.546 175.952 120.909L174.322 121.066C174.53 122.368 175.624 123.138 177.364 123.138C179.137 123.138 180.388 122.22 180.393 120.876C180.388 119.865 179.737 119.247 178.398 118.956L177.189 118.697C176.469 118.536 176.183 118.287 176.187 117.876C176.183 117.396 176.713 117.063 177.411 117.063C178.181 117.063 178.588 117.483 178.717 117.95L180.24 117.783Z" fill="#7A7A8B"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M41.9225 158.17L46.7731 163.021C47.0794 163.338 47.0751 163.842 46.7632 164.154C46.4514 164.466 45.9472 164.47 45.63 164.164L40.7794 159.313C38.2331 161.258 34.6153 160.895 32.5056 158.484C30.396 156.073 30.517 152.439 32.7825 150.173C35.048 147.908 38.6819 147.787 41.0932 149.896C43.5044 152.006 43.867 155.624 41.9225 158.17ZM32.6605 154.497C32.6605 156.953 34.6511 158.944 37.1068 158.944C39.5613 158.941 41.5504 156.952 41.5531 154.497C41.5531 152.042 39.5624 150.051 37.1068 150.051C34.6511 150.051 32.6605 152.042 32.6605 154.497Z" fill="#7A7A8B"/> +<path d="M55.9972 161H57.0866V156.568C57.0866 155.529 57.8438 154.795 58.6932 154.795C59.5195 154.795 60.0966 155.336 60.0966 156.143V161H61.2045V156.384C61.2045 155.469 61.777 154.795 62.7741 154.795C63.5497 154.795 64.2145 155.206 64.2145 156.254V161H65.304V156.254C65.304 154.588 64.4084 153.817 63.1435 153.817C62.1278 153.817 61.3846 154.283 61.0199 155.017H60.946C60.5952 154.26 59.9766 153.817 59.044 153.817C58.1207 153.817 57.4375 154.26 57.142 155.017H57.0497V153.909H55.9972V161ZM70.1767 161.148C72.0971 161.148 73.3897 159.689 73.3897 157.491C73.3897 155.276 72.0971 153.817 70.1767 153.817C68.2562 153.817 66.9636 155.276 66.9636 157.491C66.9636 159.689 68.2562 161.148 70.1767 161.148ZM70.1767 160.169C68.7179 160.169 68.0531 158.913 68.0531 157.491C68.0531 156.07 68.7179 154.795 70.1767 154.795C71.6355 154.795 72.3002 156.07 72.3002 157.491C72.3002 158.913 71.6355 160.169 70.1767 160.169ZM79.5216 158.101C79.5216 159.43 78.5059 160.04 77.6934 160.04C76.7886 160.04 76.1423 159.375 76.1423 158.341V153.909H75.0528V158.415C75.0528 160.224 76.0131 161.092 77.3426 161.092C78.4136 161.092 79.1153 160.52 79.4477 159.8H79.5216V161H80.6111V153.909H79.5216V158.101ZM83.696 156.734C83.696 155.497 84.4624 154.795 85.5057 154.795C86.5167 154.795 87.1307 155.456 87.1307 156.568V161H88.2202V156.494C88.2202 154.685 87.2553 153.817 85.8196 153.817C84.7486 153.817 84.0838 154.297 83.7514 155.017H83.6591V153.909H82.6065V161H83.696V156.734ZM93.2948 153.909H91.7806V152.21H90.6911V153.909H89.6201V154.832H90.6911V159.264C90.6911 160.501 91.6883 161.092 92.6116 161.092C93.0178 161.092 93.2764 161.018 93.4241 160.963L93.2025 159.984C93.1102 160.003 92.9624 160.04 92.7224 160.04C92.2423 160.04 91.7806 159.892 91.7806 158.969V154.832H93.2948V153.909ZM101.563 161.148C103.003 161.148 104.056 160.428 104.388 159.357L103.336 159.061C103.059 159.8 102.417 160.169 101.563 160.169C100.284 160.169 99.4024 159.343 99.3517 157.824H104.499V157.362C104.499 154.722 102.929 153.817 101.452 153.817C99.5317 153.817 98.2575 155.331 98.2575 157.51C98.2575 159.689 99.5132 161.148 101.563 161.148ZM99.3517 156.882C99.4255 155.779 100.206 154.795 101.452 154.795C102.634 154.795 103.391 155.682 103.391 156.882H99.3517ZM111.739 153.909H110.557L108.6 159.56H108.526L106.568 153.909H105.387L108.009 161H109.117L111.739 153.909ZM115.896 161.148C117.336 161.148 118.389 160.428 118.721 159.357L117.669 159.061C117.392 159.8 116.75 160.169 115.896 160.169C114.617 160.169 113.735 159.343 113.685 157.824H118.832V157.362C118.832 154.722 117.262 153.817 115.785 153.817C113.865 153.817 112.591 155.331 112.591 157.51C112.591 159.689 113.846 161.148 115.896 161.148ZM113.685 156.882C113.759 155.779 114.539 154.795 115.785 154.795C116.967 154.795 117.724 155.682 117.724 156.882H113.685ZM120.489 161H121.579V156.513C121.579 155.553 122.336 154.851 123.37 154.851C123.661 154.851 123.961 154.906 124.035 154.925V153.817C123.91 153.808 123.624 153.798 123.462 153.798C122.613 153.798 121.874 154.278 121.616 154.98H121.542V153.909H120.489V161ZM128.083 161.148C129.524 161.148 130.576 160.428 130.909 159.357L129.856 159.061C129.579 159.8 128.938 160.169 128.083 160.169C126.805 160.169 125.923 159.343 125.872 157.824H131.02V157.362C131.02 154.722 129.45 153.817 127.973 153.817C126.052 153.817 124.778 155.331 124.778 157.51C124.778 159.689 126.034 161.148 128.083 161.148ZM125.872 156.882C125.946 155.779 126.726 154.795 127.973 154.795C129.154 154.795 129.912 155.682 129.912 156.882H125.872ZM137.7 155.497C137.358 154.491 136.592 153.817 135.188 153.817C133.692 153.817 132.585 154.666 132.585 155.866C132.585 156.845 133.166 157.501 134.468 157.805L135.65 158.082C136.365 158.249 136.702 158.59 136.702 159.08C136.702 159.689 136.056 160.188 135.04 160.188C134.15 160.188 133.591 159.804 133.397 159.043L132.363 159.301C132.617 160.506 133.609 161.148 135.059 161.148C136.707 161.148 137.829 160.248 137.829 159.024C137.829 158.036 137.21 157.413 135.945 157.104L134.893 156.845C134.053 156.637 133.674 156.356 133.674 155.811C133.674 155.202 134.32 154.759 135.188 154.759C136.139 154.759 136.532 155.285 136.721 155.774L137.7 155.497ZM142.553 153.909H141.038V152.21H139.949V153.909H138.878V154.832H139.949V159.264C139.949 160.501 140.946 161.092 141.869 161.092C142.276 161.092 142.534 161.018 142.682 160.963L142.46 159.984C142.368 160.003 142.22 160.04 141.98 160.04C141.5 160.04 141.038 159.892 141.038 158.969V154.832H142.553V153.909ZM150.802 161.138C152.584 161.138 153.72 160.081 153.84 158.576H152.243C152.1 159.338 151.55 159.777 150.816 159.777C149.773 159.777 149.099 158.904 149.099 157.455C149.099 156.023 149.787 155.165 150.816 155.165C151.619 155.165 152.113 155.682 152.243 156.365H153.84C153.725 154.828 152.524 153.817 150.793 153.817C148.716 153.817 147.405 155.317 147.405 157.482C147.405 159.629 148.683 161.138 150.802 161.138ZM157.253 161.143C158.365 161.143 159.03 160.621 159.335 160.026H159.39V161H160.997V156.254C160.997 154.38 159.469 153.817 158.116 153.817C156.625 153.817 155.48 154.482 155.111 155.774L156.671 155.996C156.837 155.511 157.308 155.096 158.125 155.096C158.901 155.096 159.325 155.493 159.325 156.19V156.217C159.325 156.697 158.822 156.721 157.571 156.854C156.195 157.002 154.88 157.413 154.88 159.01C154.88 160.404 155.9 161.143 157.253 161.143ZM157.687 159.915C156.99 159.915 156.491 159.597 156.491 158.983C156.491 158.341 157.05 158.073 157.797 157.967C158.236 157.907 159.113 157.796 159.33 157.621V158.456C159.33 159.246 158.693 159.915 157.687 159.915ZM166.269 153.909H164.796V153.35C164.796 152.797 165.027 152.487 165.65 152.487C165.913 152.487 166.135 152.547 166.273 152.589L166.61 151.296C166.393 151.222 165.918 151.102 165.341 151.102C164.108 151.102 163.125 151.809 163.125 153.24V153.909H162.077V155.202H163.125V161H164.796V155.202H166.269V153.909ZM170.45 161.138C172.103 161.138 173.238 160.331 173.534 159.098L171.973 158.923C171.747 159.523 171.193 159.837 170.473 159.837C169.393 159.837 168.677 159.126 168.663 157.912H173.603V157.399C173.603 154.911 172.107 153.817 170.362 153.817C168.331 153.817 167.006 155.308 167.006 157.496C167.006 159.721 168.313 161.138 170.45 161.138ZM168.668 156.785C168.719 155.88 169.388 155.119 170.385 155.119C171.346 155.119 171.992 155.82 172.001 156.785H168.668Z" fill="#7A7A8B"/> +<path d="M29.8807 192H30.7614V188.747H33.9148V187.966H30.7614V185.509H34.2415V184.727H29.8807V192ZM35.6362 192H36.4743V186.545H35.6362V192ZM36.0623 185.636C36.389 185.636 36.6589 185.381 36.6589 185.068C36.6589 184.756 36.389 184.5 36.0623 184.5C35.7356 184.5 35.4657 184.756 35.4657 185.068C35.4657 185.381 35.7356 185.636 36.0623 185.636ZM38.0092 192H38.8473V188.548C38.8473 187.81 39.4297 187.27 40.2251 187.27C40.4489 187.27 40.6797 187.312 40.7365 187.327V186.474C40.6406 186.467 40.4205 186.46 40.2962 186.46C39.6428 186.46 39.0746 186.83 38.8757 187.369H38.8189V186.545H38.0092V192ZM43.8509 192.114C44.9588 192.114 45.7685 191.56 46.0241 190.736L45.2145 190.509C45.0014 191.077 44.5078 191.361 43.8509 191.361C42.8672 191.361 42.1889 190.725 42.1499 189.557H46.1094V189.202C46.1094 187.17 44.902 186.474 43.7656 186.474C42.2884 186.474 41.3082 187.639 41.3082 189.315C41.3082 190.991 42.2741 192.114 43.8509 192.114ZM42.1499 188.832C42.2067 187.984 42.8068 187.227 43.7656 187.227C44.6747 187.227 45.2571 187.909 45.2571 188.832H42.1499ZM49.8132 186.545H48.5916V185.991C48.5916 185.452 48.8189 185.168 49.3729 185.168C49.6143 185.168 49.7564 185.224 49.8416 185.253L50.0831 184.528C49.9553 184.472 49.6996 184.386 49.2876 184.386C48.5064 184.386 47.7536 184.855 47.7536 185.793V186.545H46.8729V187.256H47.7536V192H48.5916V187.256H49.8132V186.545ZM52.979 192.114C54.4563 192.114 55.4506 190.991 55.4506 189.301C55.4506 187.597 54.4563 186.474 52.979 186.474C51.5018 186.474 50.5075 187.597 50.5075 189.301C50.5075 190.991 51.5018 192.114 52.979 192.114ZM52.979 191.361C51.8569 191.361 51.3455 190.395 51.3455 189.301C51.3455 188.207 51.8569 187.227 52.979 187.227C54.1012 187.227 54.6126 188.207 54.6126 189.301C54.6126 190.395 54.1012 191.361 52.979 191.361ZM57.1303 186.545H56.1644L57.8974 189.273L56.1644 192H57.1303L58.4371 189.884L59.744 192H60.7099L58.9485 189.273L60.7099 186.545H59.744L58.4371 188.776L57.1303 186.545ZM68.8091 186.545H69.6614C69.6223 185.448 68.6103 184.628 67.204 184.628C65.812 184.628 64.7182 185.438 64.7182 186.659C64.7182 187.639 65.4284 188.222 66.5648 188.548L67.4597 188.804C68.2267 189.017 68.9086 189.287 68.9086 190.011C68.9086 190.807 68.1415 191.332 67.133 191.332C66.2665 191.332 65.4995 190.949 65.4284 190.125H64.5194C64.6046 191.318 65.5705 192.128 67.133 192.128C68.8091 192.128 69.7608 191.205 69.7608 190.026C69.7608 188.662 68.4682 188.222 67.7154 188.023L66.9767 187.824C66.437 187.682 65.5705 187.398 65.5705 186.616C65.5705 185.92 66.2097 185.409 67.1756 185.409C68.0563 185.409 68.7239 185.828 68.8091 186.545ZM74.5327 189.77C74.5327 190.793 73.7514 191.261 73.1264 191.261C72.4304 191.261 71.9332 190.75 71.9332 189.955V186.545H71.0952V190.011C71.0952 191.403 71.8338 192.071 72.8565 192.071C73.6804 192.071 74.2202 191.631 74.4759 191.077H74.5327V192H75.3707V186.545H74.5327V189.77ZM79.1074 194.159C80.4142 194.159 81.4654 193.562 81.4654 192.156V186.545H80.6557V187.412H80.5705C80.3858 187.128 80.0449 186.474 78.9796 186.474C77.6017 186.474 76.65 187.568 76.65 189.244C76.65 190.949 77.6444 191.915 78.9654 191.915C80.0307 191.915 80.3716 191.29 80.5563 190.991H80.6273V192.099C80.6273 193.009 79.9881 193.42 79.1074 193.42C78.1167 193.42 77.7686 192.898 77.5449 192.597L76.8773 193.065C77.2182 193.637 77.8894 194.159 79.1074 194.159ZM79.079 191.162C78.0279 191.162 77.4881 190.366 77.4881 189.23C77.4881 188.122 78.0137 187.227 79.079 187.227C80.1017 187.227 80.6415 188.051 80.6415 189.23C80.6415 190.438 80.0875 191.162 79.079 191.162ZM85.2012 194.159C86.508 194.159 87.5591 193.562 87.5591 192.156V186.545H86.7495V187.412H86.6642C86.4796 187.128 86.1387 186.474 85.0733 186.474C83.6955 186.474 82.7438 187.568 82.7438 189.244C82.7438 190.949 83.7381 191.915 85.0591 191.915C86.1245 191.915 86.4654 191.29 86.65 190.991H86.7211V192.099C86.7211 193.009 86.0819 193.42 85.2012 193.42C84.2104 193.42 83.8624 192.898 83.6387 192.597L82.9711 193.065C83.312 193.637 83.9831 194.159 85.2012 194.159ZM85.1728 191.162C84.1216 191.162 83.5819 190.366 83.5819 189.23C83.5819 188.122 84.1074 187.227 85.1728 187.227C86.1955 187.227 86.7353 188.051 86.7353 189.23C86.7353 190.438 86.1813 191.162 85.1728 191.162ZM91.3801 192.114C92.4881 192.114 93.2978 191.56 93.5534 190.736L92.7438 190.509C92.5307 191.077 92.0371 191.361 91.3801 191.361C90.3965 191.361 89.7182 190.725 89.6792 189.557H93.6387V189.202C93.6387 187.17 92.4313 186.474 91.2949 186.474C89.8176 186.474 88.8375 187.639 88.8375 189.315C88.8375 190.991 89.8034 192.114 91.3801 192.114ZM89.6792 188.832C89.736 187.984 90.3361 187.227 91.2949 187.227C92.204 187.227 92.7864 187.909 92.7864 188.832H89.6792ZM98.7772 187.767C98.5144 186.993 97.9249 186.474 96.8453 186.474C95.6948 186.474 94.8425 187.128 94.8425 188.051C94.8425 188.804 95.29 189.308 96.2914 189.543L97.2005 189.756C97.7509 189.884 98.0101 190.146 98.0101 190.523C98.0101 190.991 97.513 191.375 96.7317 191.375C96.0463 191.375 95.6167 191.08 95.4675 190.494L94.6721 190.693C94.8674 191.62 95.6309 192.114 96.7459 192.114C98.0137 192.114 98.8766 191.421 98.8766 190.48C98.8766 189.72 98.4007 189.241 97.4277 189.003L96.6181 188.804C95.9718 188.644 95.6806 188.428 95.6806 188.009C95.6806 187.54 96.1777 187.199 96.8453 187.199C97.5769 187.199 97.8787 187.604 98.0243 187.98L98.7772 187.767ZM102.51 186.545H101.346V185.239H100.507V186.545H99.6836V187.256H100.507V190.665C100.507 191.616 101.275 192.071 101.985 192.071C102.297 192.071 102.496 192.014 102.61 191.972L102.439 191.219C102.368 191.233 102.255 191.261 102.07 191.261C101.701 191.261 101.346 191.148 101.346 190.438V187.256H102.51V186.545Z" fill="black"/> +<rect x="23" y="202" width="588" height="38" rx="4" fill="#F0F0F4"/> +<rect x="30" y="212" width="18" height="18" fill="url(#pattern0)"/> +<path d="M58.0043 215.818V226H59.7741V218.99H59.8686L62.6825 225.97H64.005L66.8189 219.005H66.9134V226H68.6832V215.818H66.4261L63.4034 223.196H63.2841L60.2614 215.818H58.0043ZM73.9432 226.149C76.1804 226.149 77.6023 224.573 77.6023 222.212C77.6023 219.845 76.1804 218.264 73.9432 218.264C71.706 218.264 70.2841 219.845 70.2841 222.212C70.2841 224.573 71.706 226.149 73.9432 226.149ZM73.9531 224.707C72.7152 224.707 72.1087 223.604 72.1087 222.207C72.1087 220.81 72.7152 219.691 73.9531 219.691C75.1712 219.691 75.7777 220.81 75.7777 222.207C75.7777 223.604 75.1712 224.707 73.9531 224.707ZM83.997 222.788C83.997 223.952 83.1667 224.528 82.3713 224.528C81.5062 224.528 80.9295 223.917 80.9295 222.947V218.364H79.1298V223.226C79.1298 225.06 80.1738 226.099 81.6752 226.099C82.8187 226.099 83.6241 225.498 83.9721 224.643H84.0517V226H85.7967V218.364H83.997V222.788ZM89.4471 221.526C89.4471 220.422 90.1133 219.786 91.0629 219.786C91.9925 219.786 92.5494 220.397 92.5494 221.416V226H94.3491V221.138C94.354 219.308 93.31 218.264 91.734 218.264C90.5906 218.264 89.805 218.811 89.457 219.661H89.3675V218.364H87.6474V226H89.4471V221.526ZM99.9918 218.364H98.4854V216.534H96.6857V218.364H95.6019V219.756H96.6857V224.001C96.6758 225.438 97.7198 226.144 99.0721 226.104C99.5842 226.089 99.9371 225.99 100.131 225.925L99.8278 224.518C99.7283 224.543 99.5245 224.588 99.3008 224.588C98.8484 224.588 98.4854 224.429 98.4854 223.703V219.756H99.9918V218.364ZM105.049 226H111.711V224.454H106.894V221.675H111.328V220.129H106.894V217.364H111.671V215.818H105.049V226ZM120.295 218.364H118.381L116.621 224.036H116.541L114.786 218.364H112.867L115.586 226H117.575L120.295 218.364ZM124.661 226.149C126.441 226.149 127.664 225.279 127.982 223.952L126.301 223.763C126.058 224.409 125.461 224.747 124.686 224.747C123.522 224.747 122.752 223.982 122.737 222.674H128.056V222.122C128.056 219.442 126.446 218.264 124.566 218.264C122.379 218.264 120.952 219.87 120.952 222.227C120.952 224.623 122.359 226.149 124.661 226.149ZM122.742 221.461C122.797 220.487 123.517 219.666 124.591 219.666C125.625 219.666 126.321 220.422 126.331 221.461H122.742ZM129.579 226H131.379V221.511C131.379 220.541 132.11 219.855 133.099 219.855C133.402 219.855 133.78 219.91 133.934 219.96V218.304C133.77 218.274 133.487 218.254 133.288 218.254C132.413 218.254 131.682 218.751 131.404 219.636H131.324V218.364H129.579V226ZM138.196 226.149C139.976 226.149 141.199 225.279 141.517 223.952L139.837 223.763C139.593 224.409 138.996 224.747 138.221 224.747C137.058 224.747 136.287 223.982 136.272 222.674H141.592V222.122C141.592 219.442 139.981 218.264 138.102 218.264C135.914 218.264 134.487 219.87 134.487 222.227C134.487 224.623 135.894 226.149 138.196 226.149ZM136.277 221.461C136.332 220.487 137.053 219.666 138.126 219.666C139.161 219.666 139.857 220.422 139.866 221.461H136.277ZM149.15 220.382C148.901 219.089 147.867 218.264 146.077 218.264C144.238 218.264 142.985 219.169 142.99 220.581C142.985 221.695 143.671 222.43 145.138 222.734L146.44 223.007C147.141 223.161 147.469 223.445 147.469 223.877C147.469 224.399 146.903 224.792 146.047 224.792C145.222 224.792 144.685 224.434 144.531 223.748L142.776 223.917C143 225.319 144.178 226.149 146.052 226.149C147.961 226.149 149.309 225.16 149.314 223.713C149.309 222.624 148.608 221.958 147.166 221.645L145.863 221.366C145.088 221.192 144.78 220.924 144.785 220.482C144.78 219.964 145.351 219.607 146.102 219.607C146.932 219.607 147.37 220.059 147.509 220.561L149.15 220.382ZM154.611 218.364H153.105V216.534H151.305V218.364H150.221V219.756H151.305V224.001C151.295 225.438 152.339 226.144 153.691 226.104C154.203 226.089 154.556 225.99 154.75 225.925L154.447 224.518C154.347 224.543 154.144 224.588 153.92 224.588C153.468 224.588 153.105 224.429 153.105 223.703V219.756H154.611V218.364ZM161.058 222.44C161.55 222.44 161.953 222.038 161.953 221.545C161.953 221.053 161.55 220.651 161.058 220.651C160.566 220.651 160.163 221.053 160.163 221.545C160.163 222.038 160.566 222.44 161.058 222.44ZM170.004 226H171.277L173.524 217.886H173.604L175.851 226H177.124L179.908 215.818H178.655L176.527 224.111H176.428L174.26 215.818H172.868L170.7 224.111H170.601L168.473 215.818H167.22L170.004 226ZM181.285 226H182.458V218.364H181.285V226ZM181.881 217.091C182.339 217.091 182.717 216.733 182.717 216.295C182.717 215.858 182.339 215.5 181.881 215.5C181.424 215.5 181.046 215.858 181.046 216.295C181.046 216.733 181.424 217.091 181.881 217.091ZM184.607 226H185.78V223.141L186.601 222.381L189.459 226H190.951L187.486 221.625L190.712 218.364H189.26L185.92 221.764H185.78V215.818H184.607V226ZM192.222 226H193.396V218.364H192.222V226ZM192.819 217.091C193.276 217.091 193.654 216.733 193.654 216.295C193.654 215.858 193.276 215.5 192.819 215.5C192.362 215.5 191.984 215.858 191.984 216.295C191.984 216.733 192.362 217.091 192.819 217.091ZM195.545 228.864H196.718V224.827H196.817C197.076 225.244 197.573 226.159 199.045 226.159C200.954 226.159 202.286 224.628 202.286 222.202C202.286 219.795 200.954 218.264 199.025 218.264C197.533 218.264 197.076 219.179 196.817 219.577H196.678V218.364H195.545V228.864ZM196.698 222.182C196.698 220.472 197.454 219.318 198.885 219.318C200.377 219.318 201.113 220.571 201.113 222.182C201.113 223.812 200.357 225.105 198.885 225.105C197.474 225.105 196.698 223.912 196.698 222.182ZM207.278 226.159C208.829 226.159 209.962 225.384 210.32 224.23L209.187 223.912C208.888 224.707 208.197 225.105 207.278 225.105C205.9 225.105 204.951 224.215 204.896 222.58H210.439V222.082C210.439 219.239 208.749 218.264 207.158 218.264C205.09 218.264 203.718 219.895 203.718 222.241C203.718 224.588 205.07 226.159 207.278 226.159ZM204.896 221.565C204.976 220.377 205.816 219.318 207.158 219.318C208.431 219.318 209.246 220.273 209.246 221.565H204.896ZM215.108 226.159C216.579 226.159 217.077 225.244 217.335 224.827H217.474V226H218.608V215.818H217.434V219.577H217.335C217.077 219.179 216.619 218.264 215.128 218.264C213.199 218.264 211.866 219.795 211.866 222.202C211.866 224.628 213.199 226.159 215.108 226.159ZM215.267 225.105C213.795 225.105 213.04 223.812 213.04 222.182C213.04 220.571 213.775 219.318 215.267 219.318C216.699 219.318 217.454 220.472 217.454 222.182C217.454 223.912 216.679 225.105 215.267 225.105ZM220.92 226H222.093V218.364H220.92V226ZM221.516 217.091C221.974 217.091 222.351 216.733 222.351 216.295C222.351 215.858 221.974 215.5 221.516 215.5C221.059 215.5 220.681 215.858 220.681 216.295C220.681 216.733 221.059 217.091 221.516 217.091ZM226.489 226.179C227.821 226.179 228.517 225.463 228.756 224.966H228.816V226H229.989V220.969C229.989 218.543 228.14 218.264 227.165 218.264C226.012 218.264 224.699 218.662 224.103 220.054L225.216 220.452C225.475 219.895 226.086 219.298 227.205 219.298C228.284 219.298 228.816 219.87 228.816 220.849V220.889C228.816 221.456 228.239 221.406 226.847 221.585C225.43 221.769 223.884 222.082 223.884 223.832C223.884 225.324 225.037 226.179 226.489 226.179ZM226.668 225.125C225.733 225.125 225.057 224.707 225.057 223.892C225.057 222.997 225.873 222.719 226.787 222.599C227.284 222.54 228.617 222.401 228.816 222.162V223.236C228.816 224.19 228.06 225.125 226.668 225.125Z" fill="#15141A"/> +<path d="M239 221.491H234V222.273H239V221.491ZM244 221.491H239V222.273H244V221.491Z" fill="#15141A"/> +<path d="M251.23 221.719C251.23 220.767 251.837 220.227 252.665 220.227C253.453 220.227 253.929 220.724 253.929 221.591V225H254.767V221.534C254.767 220.131 254.021 219.474 252.906 219.474C252.047 219.474 251.557 219.833 251.301 220.398H251.23V217.727H250.392V225H251.23V221.719ZM258.672 219.545H257.508V218.239H256.67V219.545H255.846V220.256H256.67V223.665C256.67 224.616 257.437 225.071 258.147 225.071C258.459 225.071 258.658 225.014 258.772 224.972L258.601 224.219C258.53 224.233 258.417 224.261 258.232 224.261C257.863 224.261 257.508 224.148 257.508 223.438V220.256H258.672V219.545ZM262.305 219.545H261.14V218.239H260.302V219.545H259.479V220.256H260.302V223.665C260.302 224.616 261.069 225.071 261.78 225.071C262.092 225.071 262.291 225.014 262.405 224.972L262.234 224.219C262.163 224.233 262.05 224.261 261.865 224.261C261.496 224.261 261.14 224.148 261.14 223.438V220.256H262.305V219.545ZM263.566 227.045H264.404V224.162H264.475C264.66 224.46 265.015 225.114 266.066 225.114C267.43 225.114 268.381 224.02 268.381 222.287C268.381 220.568 267.43 219.474 266.052 219.474C264.986 219.474 264.66 220.128 264.475 220.412H264.376V219.545H263.566V227.045ZM264.39 222.273C264.39 221.051 264.93 220.227 265.952 220.227C267.018 220.227 267.543 221.122 267.543 222.273C267.543 223.438 267.003 224.361 265.952 224.361C264.944 224.361 264.39 223.509 264.39 222.273ZM273.523 220.767C273.26 219.993 272.671 219.474 271.591 219.474C270.441 219.474 269.589 220.128 269.589 221.051C269.589 221.804 270.036 222.308 271.037 222.543L271.947 222.756C272.497 222.884 272.756 223.146 272.756 223.523C272.756 223.991 272.259 224.375 271.478 224.375C270.792 224.375 270.363 224.08 270.214 223.494L269.418 223.693C269.613 224.62 270.377 225.114 271.492 225.114C272.76 225.114 273.623 224.421 273.623 223.48C273.623 222.72 273.147 222.241 272.174 222.003L271.364 221.804C270.718 221.644 270.427 221.428 270.427 221.009C270.427 220.54 270.924 220.199 271.591 220.199C272.323 220.199 272.625 220.604 272.77 220.98L273.523 220.767ZM275.495 225.057C275.847 225.057 276.134 224.769 276.134 224.418C276.134 224.066 275.847 223.778 275.495 223.778C275.143 223.778 274.856 224.066 274.856 224.418C274.856 224.769 275.143 225.057 275.495 225.057ZM275.495 221.009C275.847 221.009 276.134 220.721 276.134 220.369C276.134 220.018 275.847 219.73 275.495 219.73C275.143 219.73 274.856 220.018 274.856 220.369C274.856 220.721 275.143 221.009 275.495 221.009ZM280.209 217.386H279.442L277.098 226.094H277.865L280.209 217.386ZM283.774 217.386H283.007L280.663 226.094H281.43L283.774 217.386ZM287.054 225.114C288.162 225.114 288.972 224.56 289.227 223.736L288.418 223.509C288.205 224.077 287.711 224.361 287.054 224.361C286.07 224.361 285.392 223.725 285.353 222.557H289.312V222.202C289.312 220.17 288.105 219.474 286.969 219.474C285.491 219.474 284.511 220.639 284.511 222.315C284.511 223.991 285.477 225.114 287.054 225.114ZM285.353 221.832C285.41 220.984 286.01 220.227 286.969 220.227C287.878 220.227 288.46 220.909 288.46 221.832H285.353ZM291.425 221.719C291.425 220.767 292.015 220.227 292.817 220.227C293.595 220.227 294.067 220.735 294.067 221.591V225H294.906V221.534C294.906 220.142 294.163 219.474 293.059 219.474C292.235 219.474 291.724 219.844 291.468 220.398H291.397V219.545H290.587V225H291.425V221.719ZM297.048 225.057C297.399 225.057 297.687 224.769 297.687 224.418C297.687 224.066 297.399 223.778 297.048 223.778C296.696 223.778 296.409 224.066 296.409 224.418C296.409 224.769 296.696 225.057 297.048 225.057ZM300.427 225H301.251L302.444 220.81H302.529L303.722 225H304.546L306.208 219.545H305.327L304.148 223.707H304.091L302.941 219.545H302.046L300.881 223.722H300.824L299.645 219.545H298.765L300.427 225ZM307.316 225H308.154V219.545H307.316V225ZM307.742 218.636C308.069 218.636 308.339 218.381 308.339 218.068C308.339 217.756 308.069 217.5 307.742 217.5C307.415 217.5 307.145 217.756 307.145 218.068C307.145 218.381 307.415 218.636 307.742 218.636ZM309.689 225H310.527V222.958L311.113 222.415L313.155 225H314.22L311.745 221.875L314.05 219.545H313.013L310.626 221.974H310.527V217.727H309.689V225ZM315.128 225H315.966V219.545H315.128V225ZM315.555 218.636C315.881 218.636 316.151 218.381 316.151 218.068C316.151 217.756 315.881 217.5 315.555 217.5C315.228 217.5 314.958 217.756 314.958 218.068C314.958 218.381 315.228 218.636 315.555 218.636ZM317.501 227.045H318.339V224.162H318.411C318.595 224.46 318.95 225.114 320.001 225.114C321.365 225.114 322.317 224.02 322.317 222.287C322.317 220.568 321.365 219.474 319.987 219.474C318.922 219.474 318.595 220.128 318.411 220.412H318.311V219.545H317.501V227.045ZM318.325 222.273C318.325 221.051 318.865 220.227 319.888 220.227C320.953 220.227 321.479 221.122 321.479 222.273C321.479 223.438 320.939 224.361 319.888 224.361C318.879 224.361 318.325 223.509 318.325 222.273ZM325.882 225.114C326.99 225.114 327.8 224.56 328.055 223.736L327.246 223.509C327.033 224.077 326.539 224.361 325.882 224.361C324.898 224.361 324.22 223.725 324.181 222.557H328.141V222.202C328.141 220.17 326.933 219.474 325.797 219.474C324.32 219.474 323.339 220.639 323.339 222.315C323.339 223.991 324.305 225.114 325.882 225.114ZM324.181 221.832C324.238 220.984 324.838 220.227 325.797 220.227C326.706 220.227 327.288 220.909 327.288 221.832H324.181ZM331.475 225.114C332.526 225.114 332.881 224.46 333.066 224.162H333.165V225H333.975V217.727H333.137V220.412H333.066C332.881 220.128 332.555 219.474 331.489 219.474C330.112 219.474 329.16 220.568 329.16 222.287C329.16 224.02 330.112 225.114 331.475 225.114ZM331.589 224.361C330.538 224.361 329.998 223.438 329.998 222.273C329.998 221.122 330.523 220.227 331.589 220.227C332.612 220.227 333.151 221.051 333.151 222.273C333.151 223.509 332.597 224.361 331.589 224.361ZM335.626 225H336.464V219.545H335.626V225ZM336.053 218.636C336.379 218.636 336.649 218.381 336.649 218.068C336.649 217.756 336.379 217.5 336.053 217.5C335.726 217.5 335.456 217.756 335.456 218.068C335.456 218.381 335.726 218.636 336.053 218.636ZM339.605 225.128C340.556 225.128 341.053 224.616 341.224 224.261H341.267V225H342.105V221.406C342.105 219.673 340.784 219.474 340.088 219.474C339.264 219.474 338.326 219.759 337.9 220.753L338.695 221.037C338.88 220.639 339.317 220.213 340.116 220.213C340.887 220.213 341.267 220.621 341.267 221.321V221.349C341.267 221.754 340.855 221.719 339.86 221.847C338.848 221.978 337.744 222.202 337.744 223.452C337.744 224.517 338.568 225.128 339.605 225.128ZM339.732 224.375C339.065 224.375 338.582 224.077 338.582 223.494C338.582 222.855 339.164 222.656 339.818 222.571C340.173 222.528 341.124 222.429 341.267 222.259V223.026C341.267 223.707 340.727 224.375 339.732 224.375ZM344.245 225.057C344.597 225.057 344.884 224.769 344.884 224.418C344.884 224.066 344.597 223.778 344.245 223.778C343.893 223.778 343.606 224.066 343.606 224.418C343.606 224.769 343.893 225.057 344.245 225.057ZM348.604 225.114C350.081 225.114 351.076 223.991 351.076 222.301C351.076 220.597 350.081 219.474 348.604 219.474C347.127 219.474 346.132 220.597 346.132 222.301C346.132 223.991 347.127 225.114 348.604 225.114ZM348.604 224.361C347.482 224.361 346.971 223.395 346.971 222.301C346.971 221.207 347.482 220.227 348.604 220.227C349.726 220.227 350.238 221.207 350.238 222.301C350.238 223.395 349.726 224.361 348.604 224.361ZM352.355 225H353.193V221.548C353.193 220.81 353.775 220.27 354.571 220.27C354.795 220.27 355.025 220.312 355.082 220.327V219.474C354.986 219.467 354.766 219.46 354.642 219.46C353.988 219.46 353.42 219.83 353.221 220.369H353.165V219.545H352.355V225ZM358.111 227.159C359.418 227.159 360.469 226.562 360.469 225.156V219.545H359.66V220.412H359.574C359.39 220.128 359.049 219.474 357.983 219.474C356.606 219.474 355.654 220.568 355.654 222.244C355.654 223.949 356.648 224.915 357.969 224.915C359.035 224.915 359.376 224.29 359.56 223.991H359.631V225.099C359.631 226.009 358.992 226.42 358.111 226.42C357.121 226.42 356.773 225.898 356.549 225.597L355.881 226.065C356.222 226.637 356.893 227.159 358.111 227.159ZM358.083 224.162C357.032 224.162 356.492 223.366 356.492 222.23C356.492 221.122 357.018 220.227 358.083 220.227C359.106 220.227 359.645 221.051 359.645 222.23C359.645 223.438 359.091 224.162 358.083 224.162ZM364.574 217.386H363.807L361.464 226.094H362.231L364.574 217.386ZM366.804 225H367.627L368.821 220.81H368.906L370.099 225H370.923L372.585 219.545H371.704L370.525 223.707H370.468L369.318 219.545H368.423L367.258 223.722H367.201L366.022 219.545H365.142L366.804 225ZM373.693 225H374.531V219.545H373.693V225ZM374.119 218.636C374.446 218.636 374.716 218.381 374.716 218.068C374.716 217.756 374.446 217.5 374.119 217.5C373.792 217.5 373.522 217.756 373.522 218.068C373.522 218.381 373.792 218.636 374.119 218.636ZM376.066 225H376.904V222.958L377.49 222.415L379.532 225H380.597L378.122 221.875L380.427 219.545H379.39L377.003 221.974H376.904V217.727H376.066V225ZM381.505 225H382.343V219.545H381.505V225ZM381.931 218.636C382.258 218.636 382.528 218.381 382.528 218.068C382.528 217.756 382.258 217.5 381.931 217.5C381.605 217.5 381.335 217.756 381.335 218.068C381.335 218.381 381.605 218.636 381.931 218.636ZM386.449 217.386H385.682L383.339 226.094H384.106L386.449 217.386ZM387.393 217.727V225H388.657V219.993H388.725L390.735 224.979H391.679L393.689 220.004H393.757V225H395.021V217.727H393.409L391.25 222.997H391.164L389.005 217.727H387.393ZM398.778 225.107C400.376 225.107 401.392 223.981 401.392 222.294C401.392 220.604 400.376 219.474 398.778 219.474C397.18 219.474 396.164 220.604 396.164 222.294C396.164 223.981 397.18 225.107 398.778 225.107ZM398.785 224.077C397.901 224.077 397.468 223.288 397.468 222.29C397.468 221.293 397.901 220.494 398.785 220.494C399.655 220.494 400.088 221.293 400.088 222.29C400.088 223.288 399.655 224.077 398.785 224.077ZM405.959 222.706C405.959 223.537 405.366 223.949 404.798 223.949C404.18 223.949 403.768 223.512 403.768 222.82V219.545H402.483V223.018C402.483 224.329 403.229 225.071 404.301 225.071C405.118 225.071 405.693 224.641 405.942 224.031H405.998V225H407.245V219.545H405.959V222.706ZM409.852 221.804C409.852 221.016 410.328 220.561 411.006 220.561C411.67 220.561 412.068 220.998 412.068 221.726V225H413.354V221.527C413.357 220.22 412.612 219.474 411.486 219.474C410.669 219.474 410.108 219.865 409.859 220.472H409.795V219.545H408.567V225H409.852V221.804ZM417.384 219.545H416.308V218.239H415.023V219.545H414.249V220.54H415.023V223.572C415.016 224.599 415.761 225.103 416.727 225.075C417.093 225.064 417.345 224.993 417.484 224.947L417.267 223.942C417.196 223.96 417.05 223.991 416.891 223.991C416.567 223.991 416.308 223.878 416.308 223.359V220.54H417.384V219.545ZM422.49 225H417.792V226.026H422.49V225ZM423.194 225H427.952V223.896H424.511V221.911H427.679V220.806H424.511V218.832H427.924V217.727H423.194V225ZM434.083 219.545H432.716L431.459 223.597H431.402L430.149 219.545H428.778L430.721 225H432.141L434.083 219.545ZM437.202 225.107C438.474 225.107 439.347 224.485 439.574 223.537L438.374 223.402C438.2 223.864 437.774 224.105 437.22 224.105C436.389 224.105 435.839 223.558 435.828 222.624H439.628V222.23C439.628 220.316 438.477 219.474 437.135 219.474C435.572 219.474 434.553 220.621 434.553 222.305C434.553 224.016 435.558 225.107 437.202 225.107ZM435.831 221.758C435.871 221.062 436.385 220.476 437.153 220.476C437.891 220.476 438.388 221.016 438.395 221.758H435.831ZM440.715 225H442.001V221.793C442.001 221.101 442.523 220.611 443.229 220.611C443.446 220.611 443.716 220.65 443.826 220.685V219.503C443.709 219.482 443.506 219.467 443.364 219.467C442.739 219.467 442.217 219.822 442.018 220.455H441.962V219.545H440.715V225ZM446.87 225.107C448.142 225.107 449.015 224.485 449.242 223.537L448.042 223.402C447.868 223.864 447.442 224.105 446.888 224.105C446.057 224.105 445.507 223.558 445.496 222.624H449.296V222.23C449.296 220.316 448.145 219.474 446.803 219.474C445.24 219.474 444.221 220.621 444.221 222.305C444.221 224.016 445.226 225.107 446.87 225.107ZM445.499 221.758C445.539 221.062 446.053 220.476 446.82 220.476C447.559 220.476 448.056 221.016 448.063 221.758H445.499ZM454.694 220.987C454.517 220.064 453.778 219.474 452.5 219.474C451.186 219.474 450.291 220.121 450.294 221.129C450.291 221.925 450.781 222.45 451.828 222.667L452.759 222.862C453.26 222.972 453.494 223.175 453.494 223.484C453.494 223.857 453.089 224.137 452.478 224.137C451.889 224.137 451.505 223.881 451.395 223.391L450.142 223.512C450.301 224.513 451.143 225.107 452.482 225.107C453.846 225.107 454.808 224.4 454.811 223.366C454.808 222.589 454.307 222.113 453.277 221.889L452.347 221.69C451.793 221.566 451.573 221.374 451.576 221.058C451.573 220.689 451.981 220.433 452.517 220.433C453.11 220.433 453.423 220.756 453.522 221.115L454.694 220.987ZM458.595 219.545H457.519V218.239H456.234V219.545H455.46V220.54H456.234V223.572C456.227 224.599 456.972 225.103 457.938 225.075C458.304 225.064 458.556 224.993 458.695 224.947L458.478 223.942C458.407 223.96 458.261 223.991 458.102 223.991C457.778 223.991 457.519 223.878 457.519 223.359V220.54H458.595V219.545Z" fill="#0060DF"/> +<path d="M38.5 254C36.5109 254 34.6032 254.79 33.1967 256.197C31.7902 257.603 31 259.511 31 261.5C31 263.489 31.7902 265.397 33.1967 266.803C34.6032 268.21 36.5109 269 38.5 269C40.4891 269 42.3968 268.21 43.8033 266.803C45.2098 265.397 46 263.489 46 261.5C46 259.511 45.2098 257.603 43.8033 256.197C42.3968 254.79 40.4891 254 38.5 254ZM40.947 255.75C41.9738 256.191 42.8637 256.899 43.5242 257.8C44.1848 258.701 44.5918 259.763 44.703 260.875H42.474C42.3509 259.013 41.6778 257.229 40.54 255.75H40.947ZM38.898 255.75C40.2511 257.132 41.073 258.947 41.219 260.875H35.781C35.927 258.947 36.7489 257.132 38.102 255.75H38.898ZM36.052 255.75H36.46C35.3226 257.229 34.6496 259.013 34.526 260.875H32.297C32.408 259.763 32.8149 258.701 33.4752 257.8C34.1356 256.899 35.0254 256.191 36.052 255.75ZM36.052 267.25C35.0252 266.809 34.1354 266.101 33.475 265.2C32.8146 264.299 32.4078 263.237 32.297 262.125H34.526C34.6491 263.987 35.3222 265.771 36.46 267.25H36.052ZM38.102 267.25C36.7489 265.868 35.927 264.053 35.781 262.125H41.218C41.072 264.053 40.2501 265.868 38.897 267.25H38.102ZM40.948 267.25H40.539C41.6772 265.771 42.3504 263.987 42.473 262.125H44.702C44.5912 263.237 44.1845 264.298 43.5243 265.2C42.8641 266.101 41.9745 266.809 40.948 267.25Z" fill="#7A7A8B"/> +<path d="M56.1449 256.545V266H57.2159V258.817H57.3082L60.2628 266H61.2969L64.2514 258.817H64.3438V266H65.4148V256.545H64.0483L60.8352 264.393H60.7244L57.5114 256.545H56.1449ZM70.6464 258.909H69.1322V257.21H68.0427V258.909H66.9717V259.832H68.0427V264.264C68.0427 265.501 69.0399 266.092 69.9632 266.092C70.3694 266.092 70.6279 266.018 70.7757 265.963L70.5541 264.984C70.4617 265.003 70.314 265.04 70.074 265.04C69.5938 265.04 69.1322 264.892 69.1322 263.969V259.832H70.6464V258.909ZM76.0892 266H81.8691V264.984H77.2341V261.771H81.4997V260.756H77.2341V257.561H81.7952V256.545H76.0892V266ZM89.5093 258.909H88.3275L86.3701 264.56H86.2963L84.3389 258.909H83.157L85.7792 266H86.8872L89.5093 258.909ZM93.6665 266.148C95.1068 266.148 96.1594 265.428 96.4917 264.357L95.4392 264.061C95.1622 264.8 94.5205 265.169 93.6665 265.169C92.3877 265.169 91.5059 264.343 91.4552 262.824H96.6025V262.362C96.6025 259.722 95.0329 258.817 93.5557 258.817C91.6352 258.817 90.3611 260.331 90.3611 262.51C90.3611 264.689 91.6167 266.148 93.6665 266.148ZM91.4552 261.882C91.529 260.779 92.3092 259.795 93.5557 259.795C94.7375 259.795 95.4946 260.682 95.4946 261.882H91.4552ZM98.2599 266H99.3493V261.513C99.3493 260.553 100.106 259.851 101.141 259.851C101.431 259.851 101.731 259.906 101.805 259.925V258.817C101.681 258.808 101.394 258.798 101.233 258.798C100.383 258.798 99.6448 259.278 99.3863 259.98H99.3124V258.909H98.2599V266ZM105.854 266.148C107.294 266.148 108.347 265.428 108.679 264.357L107.627 264.061C107.35 264.8 106.708 265.169 105.854 265.169C104.575 265.169 103.693 264.343 103.643 262.824H108.79V262.362C108.79 259.722 107.22 258.817 105.743 258.817C103.823 258.817 102.549 260.331 102.549 262.51C102.549 264.689 103.804 266.148 105.854 266.148ZM103.643 261.882C103.717 260.779 104.497 259.795 105.743 259.795C106.925 259.795 107.682 260.682 107.682 261.882H103.643ZM115.47 260.497C115.128 259.491 114.362 258.817 112.959 258.817C111.463 258.817 110.355 259.666 110.355 260.866C110.355 261.845 110.937 262.501 112.239 262.805L113.42 263.082C114.136 263.249 114.473 263.59 114.473 264.08C114.473 264.689 113.827 265.188 112.811 265.188C111.92 265.188 111.361 264.804 111.168 264.043L110.133 264.301C110.387 265.506 111.38 266.148 112.829 266.148C114.478 266.148 115.599 265.248 115.599 264.024C115.599 263.036 114.981 262.413 113.716 262.104L112.663 261.845C111.823 261.637 111.445 261.356 111.445 260.811C111.445 260.202 112.091 259.759 112.959 259.759C113.91 259.759 114.302 260.285 114.491 260.774L115.47 260.497ZM120.323 258.909H118.809V257.21H117.719V258.909H116.648V259.832H117.719V264.264C117.719 265.501 118.717 266.092 119.64 266.092C120.046 266.092 120.305 266.018 120.452 265.963L120.231 264.984C120.138 265.003 119.991 265.04 119.751 265.04C119.271 265.04 118.809 264.892 118.809 263.969V259.832H120.323V258.909ZM128.48 268.807C130.179 268.807 131.546 268.031 131.546 266.203V258.909H130.493V260.036H130.382C130.142 259.666 129.699 258.817 128.314 258.817C126.523 258.817 125.286 260.239 125.286 262.418C125.286 264.634 126.578 265.889 128.296 265.889C129.681 265.889 130.124 265.077 130.364 264.689H130.456V266.129C130.456 267.311 129.625 267.847 128.48 267.847C127.192 267.847 126.74 267.168 126.449 266.776L125.581 267.385C126.025 268.128 126.897 268.807 128.48 268.807ZM128.444 264.911C127.077 264.911 126.375 263.876 126.375 262.399C126.375 260.959 127.059 259.795 128.444 259.795C129.773 259.795 130.475 260.866 130.475 262.399C130.475 263.969 129.755 264.911 128.444 264.911ZM133.54 266H134.63V261.513C134.63 260.553 135.387 259.851 136.421 259.851C136.712 259.851 137.012 259.906 137.086 259.925V258.817C136.961 258.808 136.675 258.798 136.513 258.798C135.664 258.798 134.925 259.278 134.667 259.98H134.593V258.909H133.54V266ZM141.042 266.148C142.962 266.148 144.255 264.689 144.255 262.491C144.255 260.276 142.962 258.817 141.042 258.817C139.121 258.817 137.829 260.276 137.829 262.491C137.829 264.689 139.121 266.148 141.042 266.148ZM141.042 265.169C139.583 265.169 138.918 263.913 138.918 262.491C138.918 261.07 139.583 259.795 141.042 259.795C142.501 259.795 143.165 261.07 143.165 262.491C143.165 263.913 142.501 265.169 141.042 265.169ZM147.283 266H148.354L149.906 260.553H150.016L151.567 266H152.638L154.799 258.909H153.654L152.121 264.32H152.048L150.552 258.909H149.388L147.874 264.338H147.8L146.268 258.909H145.123L147.283 266ZM161.262 260.497C160.92 259.491 160.154 258.817 158.751 258.817C157.255 258.817 156.147 259.666 156.147 260.866C156.147 261.845 156.729 262.501 158.031 262.805L159.212 263.082C159.928 263.249 160.265 263.59 160.265 264.08C160.265 264.689 159.619 265.188 158.603 265.188C157.712 265.188 157.153 264.804 156.96 264.043L155.925 264.301C156.179 265.506 157.172 266.148 158.621 266.148C160.27 266.148 161.391 265.248 161.391 264.024C161.391 263.036 160.773 262.413 159.508 262.104L158.455 261.845C157.615 261.637 157.237 261.356 157.237 260.811C157.237 260.202 157.883 259.759 158.751 259.759C159.702 259.759 160.094 260.285 160.283 260.774L161.262 260.497ZM166.835 266H167.888V264.911H168.017C168.257 265.298 168.719 266.148 170.085 266.148C171.858 266.148 173.095 264.726 173.095 262.473C173.095 260.239 171.858 258.817 170.067 258.817C168.682 258.817 168.257 259.666 168.017 260.036H167.925V256.545H166.835V266ZM167.906 262.455C167.906 260.866 168.608 259.795 169.938 259.795C171.323 259.795 172.006 260.959 172.006 262.455C172.006 263.969 171.304 265.169 169.938 265.169C168.627 265.169 167.906 264.061 167.906 262.455ZM175.21 268.659C176.193 268.659 176.885 268.137 177.296 267.034L180.306 258.909H179.124L177.167 264.56H177.093L175.136 258.909H173.954L176.576 266.037L176.373 266.591C175.976 267.671 175.487 267.773 174.693 267.57L174.416 268.53C174.526 268.585 174.84 268.659 175.21 268.659ZM186.49 261.734C186.49 260.497 187.256 259.795 188.3 259.795C189.311 259.795 189.925 260.456 189.925 261.568V266H191.014V261.494C191.014 259.685 190.049 258.817 188.614 258.817C187.543 258.817 186.878 259.297 186.545 260.017H186.453V258.909H185.4V266H186.49V261.734ZM195.978 266.148C197.418 266.148 198.471 265.428 198.803 264.357L197.751 264.061C197.474 264.8 196.832 265.169 195.978 265.169C194.699 265.169 193.817 264.343 193.767 262.824H198.914V262.362C198.914 259.722 197.344 258.817 195.867 258.817C193.947 258.817 192.673 260.331 192.673 262.51C192.673 264.689 193.928 266.148 195.978 266.148ZM193.767 261.882C193.841 260.779 194.621 259.795 195.867 259.795C197.049 259.795 197.806 260.682 197.806 261.882H193.767ZM202.658 266.166C203.895 266.166 204.542 265.501 204.763 265.04H204.819V266H205.908V261.328C205.908 259.075 204.191 258.817 203.286 258.817C202.215 258.817 200.996 259.186 200.442 260.479L201.476 260.848C201.716 260.331 202.284 259.777 203.323 259.777C204.325 259.777 204.819 260.308 204.819 261.217V261.254C204.819 261.781 204.283 261.734 202.99 261.901C201.675 262.071 200.239 262.362 200.239 263.987C200.239 265.372 201.31 266.166 202.658 266.166ZM202.824 265.188C201.956 265.188 201.328 264.8 201.328 264.043C201.328 263.212 202.086 262.953 202.935 262.842C203.397 262.787 204.634 262.658 204.819 262.436V263.433C204.819 264.32 204.117 265.188 202.824 265.188ZM207.897 266H208.986V261.513C208.986 260.553 209.743 259.851 210.777 259.851C211.068 259.851 211.368 259.906 211.442 259.925V258.817C211.317 258.808 211.031 258.798 210.87 258.798C210.02 258.798 209.282 259.278 209.023 259.98H208.949V258.909H207.897V266ZM213.823 256.545H212.733V266H213.823V256.545ZM216.52 268.659C217.503 268.659 218.196 268.137 218.607 267.034L221.617 258.909H220.435L218.478 264.56H218.404L216.446 258.909H215.264L217.887 266.037L217.684 266.591C217.286 267.671 216.797 267.773 216.003 267.57L215.726 268.53C215.837 268.585 216.151 268.659 216.52 268.659ZM228.798 266.166C230.035 266.166 230.681 265.501 230.903 265.04H230.958V266H232.048V261.328C232.048 259.075 230.33 258.817 229.426 258.817C228.354 258.817 227.136 259.186 226.582 260.479L227.616 260.848C227.856 260.331 228.424 259.777 229.462 259.777C230.464 259.777 230.958 260.308 230.958 261.217V261.254C230.958 261.781 230.423 261.734 229.13 261.901C227.814 262.071 226.379 262.362 226.379 263.987C226.379 265.372 227.45 266.166 228.798 266.166ZM228.964 265.188C228.096 265.188 227.468 264.8 227.468 264.043C227.468 263.212 228.225 262.953 229.075 262.842C229.536 262.787 230.774 262.658 230.958 262.436V263.433C230.958 264.32 230.256 265.188 228.964 265.188ZM237.692 266H238.782V261.568C238.782 260.529 239.539 259.795 240.388 259.795C241.215 259.795 241.792 260.336 241.792 261.143V266H242.9V261.384C242.9 260.469 243.472 259.795 244.469 259.795C245.245 259.795 245.91 260.206 245.91 261.254V266H246.999V261.254C246.999 259.588 246.104 258.817 244.839 258.817C243.823 258.817 243.08 259.283 242.715 260.017H242.641C242.29 259.26 241.672 258.817 240.739 258.817C239.816 258.817 239.133 259.26 238.837 260.017H238.745V258.909H237.692V266ZM251.964 266.148C253.405 266.148 254.457 265.428 254.79 264.357L253.737 264.061C253.46 264.8 252.818 265.169 251.964 265.169C250.686 265.169 249.804 264.343 249.753 262.824H254.9V262.362C254.9 259.722 253.331 258.817 251.854 258.817C249.933 258.817 248.659 260.331 248.659 262.51C248.659 264.689 249.915 266.148 251.964 266.148ZM249.753 261.882C249.827 260.779 250.607 259.795 251.854 259.795C253.035 259.795 253.792 260.682 253.792 261.882H249.753ZM259.642 258.909H258.127V257.21H257.038V258.909H255.967V259.832H257.038V264.264C257.038 265.501 258.035 266.092 258.958 266.092C259.365 266.092 259.623 266.018 259.771 265.963L259.549 264.984C259.457 265.003 259.309 265.04 259.069 265.04C258.589 265.04 258.127 264.892 258.127 263.969V259.832H259.642V258.909ZM261.28 266H262.37V261.513C262.37 260.553 263.127 259.851 264.161 259.851C264.452 259.851 264.752 259.906 264.826 259.925V258.817C264.701 258.808 264.415 258.798 264.253 258.798C263.404 258.798 262.665 259.278 262.407 259.98H262.333V258.909H261.28V266ZM268.874 266.148C270.315 266.148 271.367 265.428 271.7 264.357L270.647 264.061C270.37 264.8 269.729 265.169 268.874 265.169C267.596 265.169 266.714 264.343 266.663 262.824H271.811V262.362C271.811 259.722 270.241 258.817 268.764 258.817C266.843 258.817 265.569 260.331 265.569 262.51C265.569 264.689 266.825 266.148 268.874 266.148ZM266.663 261.882C266.737 260.779 267.517 259.795 268.764 259.795C269.945 259.795 270.703 260.682 270.703 261.882H266.663ZM280.208 258.909H278.694V257.21H277.604V258.909H276.533V259.832H277.604V264.264C277.604 265.501 278.601 266.092 279.525 266.092C279.931 266.092 280.189 266.018 280.337 265.963L280.116 264.984C280.023 265.003 279.876 265.04 279.635 265.04C279.155 265.04 278.694 264.892 278.694 263.969V259.832H280.208V258.909ZM284.651 266.148C286.572 266.148 287.864 264.689 287.864 262.491C287.864 260.276 286.572 258.817 284.651 258.817C282.731 258.817 281.438 260.276 281.438 262.491C281.438 264.689 282.731 266.148 284.651 266.148ZM284.651 265.169C283.192 265.169 282.528 263.913 282.528 262.491C282.528 261.07 283.192 259.795 284.651 259.795C286.11 259.795 286.775 261.07 286.775 262.491C286.775 263.913 286.11 265.169 284.651 265.169ZM294.273 261.734C294.273 260.497 295.04 259.795 296.083 259.795C297.094 259.795 297.708 260.456 297.708 261.568V266H298.797V261.494C298.797 259.685 297.832 258.817 296.397 258.817C295.326 258.817 294.661 259.297 294.329 260.017H294.236V258.909H293.184V266H294.273V261.734ZM303.761 266.148C305.202 266.148 306.254 265.428 306.586 264.357L305.534 264.061C305.257 264.8 304.615 265.169 303.761 265.169C302.482 265.169 301.601 264.343 301.55 262.824H306.697V262.362C306.697 259.722 305.128 258.817 303.65 258.817C301.73 258.817 300.456 260.331 300.456 262.51C300.456 264.689 301.711 266.148 303.761 266.148ZM301.55 261.882C301.624 260.779 302.404 259.795 303.65 259.795C304.832 259.795 305.589 260.682 305.589 261.882H301.55ZM309.961 266H311.032L312.583 260.553H312.694L314.245 266H315.316L317.477 258.909H316.332L314.799 264.32H314.725L313.23 258.909H312.066L310.552 264.338H310.478L308.945 258.909H307.801L309.961 266ZM323.663 261.734C323.663 260.497 324.452 259.795 325.528 259.795C326.553 259.795 327.171 260.442 327.171 261.568V266H328.261V261.494C328.261 259.671 327.291 258.817 325.842 258.817C324.725 258.817 324.088 259.283 323.755 260.017H323.663V256.545H322.573V266H323.663V261.734ZM333.227 266.148C334.667 266.148 335.72 265.428 336.052 264.357L335 264.061C334.723 264.8 334.081 265.169 333.227 265.169C331.948 265.169 331.066 264.343 331.016 262.824H336.163V262.362C336.163 259.722 334.593 258.817 333.116 258.817C331.196 258.817 329.922 260.331 329.922 262.51C329.922 264.689 331.177 266.148 333.227 266.148ZM331.016 261.882C331.09 260.779 331.87 259.795 333.116 259.795C334.298 259.795 335.055 260.682 335.055 261.882H331.016ZM337.82 266H338.91V258.909H337.82V266ZM338.374 257.727C338.799 257.727 339.15 257.395 339.15 256.989C339.15 256.582 338.799 256.25 338.374 256.25C337.95 256.25 337.599 256.582 337.599 256.989C337.599 257.395 337.95 257.727 338.374 257.727ZM343.768 268.807C345.466 268.807 346.833 268.031 346.833 266.203V258.909H345.78V260.036H345.67C345.43 259.666 344.986 258.817 343.601 258.817C341.81 258.817 340.573 260.239 340.573 262.418C340.573 264.634 341.866 265.889 343.583 265.889C344.968 265.889 345.411 265.077 345.651 264.689H345.743V266.129C345.743 267.311 344.912 267.847 343.768 267.847C342.48 267.847 342.027 267.168 341.736 266.776L340.868 267.385C341.312 268.128 342.184 268.807 343.768 268.807ZM343.731 264.911C342.364 264.911 341.662 263.876 341.662 262.399C341.662 260.959 342.346 259.795 343.731 259.795C345.06 259.795 345.762 260.866 345.762 262.399C345.762 263.969 345.042 264.911 343.731 264.911ZM349.917 261.734C349.917 260.497 350.706 259.795 351.782 259.795C352.807 259.795 353.425 260.442 353.425 261.568V266H354.515V261.494C354.515 259.671 353.545 258.817 352.096 258.817C350.979 258.817 350.341 259.283 350.009 260.017H349.917V256.545H348.827V266H349.917V261.734ZM359.592 258.909H358.078V257.21H356.988V258.909H355.917V259.832H356.988V264.264C356.988 265.501 357.985 266.092 358.908 266.092C359.315 266.092 359.573 266.018 359.721 265.963L359.499 264.984C359.407 265.003 359.259 265.04 359.019 265.04C358.539 265.04 358.078 264.892 358.078 263.969V259.832H359.592V258.909ZM365.681 262.695C366.138 262.695 366.512 262.321 366.512 261.864C366.512 261.407 366.138 261.033 365.681 261.033C365.224 261.033 364.85 261.407 364.85 261.864C364.85 262.321 365.224 262.695 365.681 262.695ZM379.768 256.545H378.642V263.987H378.549L373.379 256.545H372.271V266H373.416V258.577H373.508L378.66 266H379.768V256.545ZM384.884 266.148C386.325 266.148 387.377 265.428 387.71 264.357L386.657 264.061C386.38 264.8 385.738 265.169 384.884 265.169C383.605 265.169 382.724 264.343 382.673 262.824H387.82V262.362C387.82 259.722 386.251 258.817 384.773 258.817C382.853 258.817 381.579 260.331 381.579 262.51C381.579 264.689 382.835 266.148 384.884 266.148ZM382.673 261.882C382.747 260.779 383.527 259.795 384.773 259.795C385.955 259.795 386.712 260.682 386.712 261.882H382.673ZM391.084 266H392.155L393.706 260.553H393.817L395.368 266H396.439L398.6 258.909H397.455L395.922 264.32H395.848L394.353 258.909H393.189L391.675 264.338H391.601L390.069 258.909H388.924L391.084 266ZM405.063 260.497C404.721 259.491 403.955 258.817 402.551 258.817C401.056 258.817 399.948 259.666 399.948 260.866C399.948 261.845 400.529 262.501 401.831 262.805L403.013 263.082C403.729 263.249 404.066 263.59 404.066 264.08C404.066 264.689 403.419 265.188 402.404 265.188C401.513 265.188 400.954 264.804 400.76 264.043L399.726 264.301C399.98 265.506 400.973 266.148 402.422 266.148C404.07 266.148 405.192 265.248 405.192 264.024C405.192 263.036 404.574 262.413 403.309 262.104L402.256 261.845C401.416 261.637 401.037 261.356 401.037 260.811C401.037 260.202 401.684 259.759 402.551 259.759C403.502 259.759 403.895 260.285 404.084 260.774L405.063 260.497Z" fill="#7A7A8B"/> +<rect x="408" y="253" width="16" height="16" fill="url(#paint0_linear_7341_14209)"/> +<rect x="615" y="164" width="4" height="120" rx="2" fill="#E0E0E6"/> +</g> +</g> +<defs> +<filter id="filter0_d_7341_14209" x="0" y="0" width="637" height="304" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset dy="2"/> +<feGaussianBlur stdDeviation="7"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7341_14209"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_7341_14209" result="shape"/> +</filter> +<pattern id="pattern0" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_7341_14209" transform="scale(0.03125)"/> +</pattern> +<linearGradient id="paint0_linear_7341_14209" x1="424" y1="269" x2="408" y2="269" gradientUnits="userSpaceOnUse"> +<stop stop-color="white"/> +<stop offset="1" stop-color="white" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_7341_14209"> +<rect x="14" y="12" width="609" height="276" rx="4" fill="white"/> +</clipPath> +<image id="image0_7341_14209" width="32" height="32" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAAE6UlEQVRYCcVXWyjmWxTfrknUjEQuxRTjkDNJeHErorwgpc45RCGKGnJ7UB49SIzLMx4USeHBk1xmStSIyOVIkVsxKcS4M+us32r2//w/vm9MTcyq/e2112/ttdZee+31x06ZiIjC7+/v/3RwcOhl3oT8OmtnZ6fY9j9se4n55UcW2eEHHkLfvn2j5xjaPs8fjAB44QIBwOdwas0mfDHBp4sd//zB0fyLiJg3AntOBtfxnULtmYnA4qWcP/AVgQyAIH9xQibsf5dznBa+cQV4Huru7g6sQlR6YA05FG9vb7E0MOhwgQmuZzMOmxga0zYxwyYwkCMEHz9+VN3d3erk5ER5enoqfquisL+/r96+fauqqqoEX1xcVK9evRL85uZGXVxcqOTkZBUXF6c6OjrU0dGR4NfX18rZ2VmlpaWpN2/eqKamJjmEu7u7+vr1q8JcUFCgYmNjFSKky8tLWlpaotraWrK3t0dByCgpKaH19XViZ/TlyxdqaWkhNzc3wby8vGhoaIg4aGKHtLKyQqmpqYKlp6fT58+f6ezsTPaCj4qKEiwvL090OaPy7CUATrEQhO/fvxdFOJqZmdGQMVdXV0uQ/v7+tLGxYcjB1NTUiKPd3V0LORZlZWUUExNDp6enBobDSwBgMEALCwsUEBBAfDXU2NhooYwFTspplSAbGhoMHBmCg4GBAZGZbR4eHhKy0tfXZ4FZDQDpLi4uFgeBgYF0cHBgbNLe6urqBPfz86OtrS0RI5isrCziujD09aHgmO+bjo+PLbBHAegNY2Njxl23trZabMJieXmZUAOoFQSzs7ND7969o8nJyUe6qC/UBheiBQZfNgO4urqSlMFBeHi4FBp26wDBl5eXSwBBQUGUnZ1NRUVFhBoCaePgZ2dnKSIigjY3N7E0MK1jUQNaCEWkDS/C0dGRenp6IDI2g5+fn6fXr19LrSAbqA2Q2QbWKL6KigqwBqZ1MNsMAIUTHR0tp0xMTCR+v4YRYfgnPz9fcB8fH+NFaOPQWVtbo7CwMHniWGvMPEsn5FRbEOtKQ8rIyBD51NSU+vTpk6EDHIQGBELDQiODHI1NU29vr4qMjFShoaGCabnFbI7GzLMx2tvbI29vbzklGghqAzqg8/NzQmb0kwwODjayABzPEnc/OjqKpdXTw5bVDCBC3qP4mamcnBwJmA0prn7jhFwjipuV6urqUh4eHmp7e1v19/cbh+N+oHx9fVVSUpLt00PbfOqHPCJfXV2VYmNVqqyshIhQH/Hx8TQxMSHr3NxcyVJISIj0BWSHr4dGRkYEf2jXvH4yAP5qUWFhoThAh8S1tLW1EX9MCBgILdvV1VV0mpubaXBwkFJSUuQbA9zs8CH/wwCgDJqeniYXFxd5cqWlpXL3c3NzguEHzQYBIUvoGwkJCdTZ2Sn4Q4cP1z8VANorejkcYNTX18vptTF4Gh8fJ/5UC47na63tan3z/GQAUAYNDw+Tk5MT4Z7RekHaEHhkITMzUwJob2+HyMC1nrX5pwPAiZBa9HRrhuAQd48r2LTRdq3twxOxatCsDB0UHPo6Nx0sH+2BDH+ccNN69E0w2zLz2IO/iv/ie8WHmqcfk+5ytnSfws3Wv+v+7cjCBQAQ2DKsN/4qru3oQHm9gE64xaOVx0sTfG4ZTvl0v+Wf0/8/XRwKB/Hi/57/B9qg6Ao4XU7pAAAAAElFTkSuQmCC"/> +</defs> +</svg> diff --git a/browser/components/urlbar/docs/.rstcheck.cfg b/browser/components/urlbar/docs/.rstcheck.cfg new file mode 100644 index 0000000000..741c90297a --- /dev/null +++ b/browser/components/urlbar/docs/.rstcheck.cfg @@ -0,0 +1,13 @@ +[rstcheck] +# Suppress some rstcheck messages. Unfortunately there isn't a better way to do +# this. See: https://github.com/myint/rstcheck#ignore-specific-errors +# +# Duplicate explicit target name: "[0-9]+" +# => Allow duplicate out-of-line definitions of links to bugs, like: +# .. _1689365: https://bugzilla.mozilla.org/show_bug.cgi?id=1689365 +# That way if a bug is referenced in more than one section, you can define +# it in every section it's used, which might be saner than making sure it's +# defined in only one place. +# Enumerated list start value not ordinal-1: "0" +# => Allow numbered lists to start at 0. +ignore_messages=(Duplicate explicit target name: "[0-9]+"|Enumerated list start value not ordinal-1: "0") diff --git a/browser/components/urlbar/docs/UrlbarController.rst b/browser/components/urlbar/docs/UrlbarController.rst new file mode 100644 index 0000000000..281aa9715d --- /dev/null +++ b/browser/components/urlbar/docs/UrlbarController.rst @@ -0,0 +1,5 @@ +UrlbarController Reference +========================== + +.. js:autoclass:: UrlbarController + :members: diff --git a/browser/components/urlbar/docs/UrlbarInput.rst b/browser/components/urlbar/docs/UrlbarInput.rst new file mode 100644 index 0000000000..3c74048830 --- /dev/null +++ b/browser/components/urlbar/docs/UrlbarInput.rst @@ -0,0 +1,5 @@ +UrlbarInput Reference +===================== + +.. js:autoclass:: UrlbarInput + :members: diff --git a/browser/components/urlbar/docs/UrlbarView.rst b/browser/components/urlbar/docs/UrlbarView.rst new file mode 100644 index 0000000000..1eacd53702 --- /dev/null +++ b/browser/components/urlbar/docs/UrlbarView.rst @@ -0,0 +1,5 @@ +UrlbarView Reference +==================== + +.. js:autoclass:: UrlbarView + :members: diff --git a/browser/components/urlbar/docs/assets/lifetime/lifetime.png b/browser/components/urlbar/docs/assets/lifetime/lifetime.png Binary files differnew file mode 100644 index 0000000000..17be253027 --- /dev/null +++ b/browser/components/urlbar/docs/assets/lifetime/lifetime.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/autofill.png b/browser/components/urlbar/docs/assets/nontechnical-overview/autofill.png Binary files differnew file mode 100644 index 0000000000..78587611ee --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/autofill.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark-keyword.png b/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark-keyword.png Binary files differnew file mode 100644 index 0000000000..68dde6c88d --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark-keyword.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark.png b/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark.png Binary files differnew file mode 100644 index 0000000000..ba4500d6c6 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/bookmark.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/empty-placeholder.png b/browser/components/urlbar/docs/assets/nontechnical-overview/empty-placeholder.png Binary files differnew file mode 100644 index 0000000000..a885be7e06 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/empty-placeholder.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/empty-url.png b/browser/components/urlbar/docs/assets/nontechnical-overview/empty-url.png Binary files differnew file mode 100644 index 0000000000..fd802258de --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/empty-url.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/form-history.png b/browser/components/urlbar/docs/assets/nontechnical-overview/form-history.png Binary files differnew file mode 100644 index 0000000000..ae4e236f99 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/form-history.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/history.png b/browser/components/urlbar/docs/assets/nontechnical-overview/history.png Binary files differnew file mode 100644 index 0000000000..1b6de1e76f --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/history.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-clear.png b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-clear.png Binary files differnew file mode 100644 index 0000000000..56780bc169 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-clear.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-refresh.png b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-refresh.png Binary files differnew file mode 100644 index 0000000000..1b8d87cc72 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-refresh.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-update.png b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-update.png Binary files differnew file mode 100644 index 0000000000..41af8421d6 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/intervention-update.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/non-empty.png b/browser/components/urlbar/docs/assets/nontechnical-overview/non-empty.png Binary files differnew file mode 100644 index 0000000000..3949d4c407 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/non-empty.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/open-tab.png b/browser/components/urlbar/docs/assets/nontechnical-overview/open-tab.png Binary files differnew file mode 100644 index 0000000000..d063540981 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/open-tab.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-privacy.png b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-privacy.png Binary files differnew file mode 100644 index 0000000000..cca5864dbb --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-privacy.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-show-suggestions.png b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-show-suggestions.png Binary files differnew file mode 100644 index 0000000000..4a0f019798 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-show-suggestions.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-suggestions-first.png b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-suggestions-first.png Binary files differnew file mode 100644 index 0000000000..09ec455563 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/prefs-suggestions-first.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/remote-tab.png b/browser/components/urlbar/docs/assets/nontechnical-overview/remote-tab.png Binary files differnew file mode 100644 index 0000000000..64c75da6c6 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/remote-tab.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-heuristic.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-heuristic.png Binary files differnew file mode 100644 index 0000000000..6d84db2f04 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-heuristic.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-mode.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-mode.png Binary files differnew file mode 100644 index 0000000000..42cb34d6e0 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-mode.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers-selected.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers-selected.png Binary files differnew file mode 100644 index 0000000000..402f6cd19b --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers-selected.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers.png Binary files differnew file mode 100644 index 0000000000..b61f54f432 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-offers.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-suggestion.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-suggestion.png Binary files differnew file mode 100644 index 0000000000..0435615467 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-suggestion.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-onboard.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-onboard.png Binary files differnew file mode 100644 index 0000000000..859b688e1a --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-onboard.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-redirect.png b/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-redirect.png Binary files differnew file mode 100644 index 0000000000..e34d53f12b --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/search-tip-redirect.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-onboard.png b/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-onboard.png Binary files differnew file mode 100644 index 0000000000..204066e9ce --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-onboard.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-regular.png b/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-regular.png Binary files differnew file mode 100644 index 0000000000..de03d2f0eb --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/tab-to-search-regular.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/tail-suggestions.png b/browser/components/urlbar/docs/assets/nontechnical-overview/tail-suggestions.png Binary files differnew file mode 100644 index 0000000000..fd7f098a98 --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/tail-suggestions.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/top-sites.png b/browser/components/urlbar/docs/assets/nontechnical-overview/top-sites.png Binary files differnew file mode 100644 index 0000000000..6818b1c17d --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/top-sites.png diff --git a/browser/components/urlbar/docs/assets/nontechnical-overview/visit.png b/browser/components/urlbar/docs/assets/nontechnical-overview/visit.png Binary files differnew file mode 100644 index 0000000000..a0b182dd8f --- /dev/null +++ b/browser/components/urlbar/docs/assets/nontechnical-overview/visit.png diff --git a/browser/components/urlbar/docs/contact.rst b/browser/components/urlbar/docs/contact.rst new file mode 100644 index 0000000000..abd9947528 --- /dev/null +++ b/browser/components/urlbar/docs/contact.rst @@ -0,0 +1,9 @@ +Getting in Touch +================ + +For any questions regarding the Address Bar, the team is available through +the #search channel on Slack and the fx-search@mozilla.com mailing +list. + +Issues can be `filed in Bugzilla <https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Address%20Bar>`_ +under the Firefox / Address Bar component. diff --git a/browser/components/urlbar/docs/debugging.rst b/browser/components/urlbar/docs/debugging.rst new file mode 100644 index 0000000000..689657c068 --- /dev/null +++ b/browser/components/urlbar/docs/debugging.rst @@ -0,0 +1,4 @@ +Debugging & Logging +=================== + +*Content to be written* diff --git a/browser/components/urlbar/docs/dynamic-result-types.rst b/browser/components/urlbar/docs/dynamic-result-types.rst new file mode 100644 index 0000000000..a3bf24593f --- /dev/null +++ b/browser/components/urlbar/docs/dynamic-result-types.rst @@ -0,0 +1,806 @@ +Dynamic Result Types +==================== + +This document discusses a special category of address bar results called dynamic +result types. Dynamic result types allow you to easily add new types of results +to the address bar and are especially useful for extensions. + +The intended audience for this document is developers who need to add new kinds +of address bar results, either internally in the address bar codebase or through +extensions. + +.. contents:: + :depth: 2 + + +Motivation +---------- + +The address bar provides many different types of results in normal Firefox +usage. For example, when you type a search term, the address bar may show you +search suggestion results from your current search engine. It may also show you +results from your browsing history that match your search. If you typed a +certain phrase like "update Firefox," it will show you a tip result that lets +you know whether Firefox is up to date. + +Each of these types of results is built into the address bar implementation. If +you wanted to add a new type of result -- say, a card that shows the weather +forecast when the user types "weather" -- one way to do so would be to add a new +result type. You would need to update all the code paths in the address bar that +relate to result types. For instance, you'd need to update the code path that +handles clicks on results so that your weather card opens an appropriate +forecast URL when clicked; you'd need to update the address bar view (the panel) +so that your card is drawn correctly; you may need to update the keyboard +selection behavior if your card contains elements that can be independently +selected such as different days of the week; and so on. + +If you're implementing your weather card in an extension, as you might in an +add-on experiment, then you'd need to land your new result type in +mozilla-central so your extension can use it. Your new result type would ship +with Firefox even though the vast majority of users would never see it, and your +fellow address bar hackers would have to work around your code even though it +would remain inactive most of the time, at least until your experiment +graduated. + +Dynamic Result Types +-------------------- + +**Dynamic result types** are an alternative way of implementing new result +types. Instead of adding a new built-in type along with all that entails, you +add a new provider subclass and register a template that describes how the view +should draw your result type and indicates which elements are selectable. The +address bar takes care of everything else. (Or if you're implementing an +extension, you add a few event handlers instead of a provider subclass, although +we have a shim_ that abstracts away the differences between internal and +extension address bar code.) + +Dynamic result types are essentially an abstraction layer: Support for them as a +general category of results is built into the address bar, and each +implementation of a specific dynamic result type fills in the details. + +In addition, dynamic result types can be added at runtime. This is important for +extensions that implement new types of results like the weather forecast example +above. + +.. _shim: https://github.com/0c0w3/dynamic-result-type-extension/blob/master/src/shim.js + +Getting Started +--------------- + +To get a feel for how dynamic result types are implemented, you can look at the +`example dynamic result type extension <exampleExtension_>`__. The extension +uses the recommended shim_ that makes writing address bar extension code very +similar to writing internal address bar code, and it's therefore a useful +example even if you intend to add a new dynamic result type internally in the +address bar codebase in mozilla-central. + +The next section describes the specific steps you need to take to add a new +dynamic result type. + +.. _exampleExtension: https://github.com/0c0w3/dynamic-result-type-extension/blob/master/src/background.js + +Implementation Steps +-------------------- + +This section describes how to add a new dynamic result type in either of the +following cases: + +* You want to add a new dynamic result type in an extension using the + recommended shim_. +* You want to add a new dynamic result type internal to the address bar codebase + in mozilla-central. + +The steps are mostly the same in both cases and are described next. + +If you want to add a new dynamic result type in an extension but don't want to +use the shim, then skip ahead to `Appendix B: Using the WebExtensions API +Directly`_. + +1. Register the dynamic result type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, register the new dynamic result type: + +.. code-block:: javascript + + UrlbarResult.addDynamicResultType(name); + +``name`` is a string identifier for the new type. It must be unique; that is, it +must be different from all other dynamic result type names. It will also be used +in DOM IDs, DOM class names, and CSS selectors, so it should not contain any +spaces or other characters that are invalid in CSS. + +2. Register the view template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Next, add the view template for the new type: + +.. code-block:: javascript + + UrlbarView.addDynamicViewTemplate(name, viewTemplate); + +``name`` is the new type's name as described in step 1. + +``viewTemplate`` is an object called a view template. It describes in a +declarative manner the DOM that should be created in the view for all results of +the new type. For providers created in extensions, it also declares the +stylesheet that should be applied to results in the view. See `View Templates`_ +for a description of this object. + +3. Add the provider +~~~~~~~~~~~~~~~~~~~ + +As with any type of result, results for dynamic result types must be created by +one or more providers. Make a ``UrlbarProvider`` subclass for the new provider +and implement all the usual provider methods as you normally would: + +.. code-block:: javascript + + class MyDynamicResultTypeProvider extends UrlbarProvider { + // ... + } + +The ``startQuery`` method should create ``UrlbarResult`` objects with the +following two requirements: + +* Result types must be ``UrlbarUtils.RESULT_TYPE.DYNAMIC``. +* Result payloads must have a ``dynamicType`` property whose value is the name + of the dynamic result type used in step 1. + +The results' sources, other payload properties, and other result properties +aren't relevant to dynamic result types, and you should choose values +appropriate to your use case. + +If any elements created in the view for your results can be picked with the +keyboard or mouse, then be sure to implement your provider's ``onEngagement`` +method. + +For help on implementing providers in general, see the address bar's +`Architecture Overview`__. + +If you are creating the provider in the internal address bar implementation in +mozilla-central, then don't forget to register it in ``UrlbarProvidersManager``. + +If you are creating the provider in an extension, then it's registered +automatically, and there's nothing else you need to do. + +__ https://firefox-source-docs.mozilla.org/browser/urlbar/overview.html#urlbarprovider + +4. Implement the provider's getViewUpdate method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``getViewUpdate`` is a provider method particular to dynamic result type +providers. Its job is to update the view DOM for a specific result. It's called +by the view for each result in the view that was created by the provider. It +returns an object called a view update object. + +Recall that the view template was added earlier, in step 2. The view template +describes how to build the DOM structure for all results of the dynamic result +type. The view update object, in this step, describes how to fill in that +structure for a specific result. + +Add the ``getViewUpdate`` method to the provider: + +.. code-block:: javascript + + /** + * Returns a view update object that describes how to update the view DOM + * for a given result. + * + * @param {UrlbarResult} result + * The view update object describes how to update the view DOM for this + * particular result. + * @param {Map} idsByName + * A map from names in the view template to the IDs of their corresponding + * elements in the DOM. + */ + getViewUpdate(result, idsByName) { + let viewUpdate = { + // ... + }; + return viewUpdate; + } + +``result`` is the result from the provider for which the view update is being +requested. + +``idsByName`` is a map from names in the view template to the IDs of their +corresponding elements in the DOM. This is useful if parts of the view update +depend on element IDs, as some ARIA attributes do. + +The return value is a view update object. It describes in a declarative manner +the updates that should be performed on the view DOM. See `View Update Objects`_ +for a description of this object. + +5. Style the results +~~~~~~~~~~~~~~~~~~~~ + +If you are creating the provider in the internal address bar implementation in +mozilla-central, then add styling `urlbar-dynamic-results.css`_. + +.. _urlbar-dynamic-results.css: https://searchfox.org/mozilla-central/source/browser/themes/shared/urlbar-dynamic-results.css + +If you are creating the provider in an extension, then bundle a CSS file in your +extension and declare it in the top-level ``stylesheet`` property of your view +template, as described in `View Templates`_. Additionally, if any of your rules +override built-in rules, then you'll need to declare them as ``!important``. + +The rest of this section will discuss the CSS rules you need to use to style +your results. + +There are two DOM annotations that are useful for styling. The first is the +``dynamicType`` attribute that is set on result rows, and the second is a class +that is set on child elements created from the view template. + +dynamicType Row Attribute +......................... + +The topmost element in the view corresponding to a result is called a +**row**. Rows have a class of ``urlbarView-row``, and rows corresponding to +results of a dynamic result type have an attributed called ``dynamicType``. The +value of this attribute is the name of the dynamic result type that was chosen +in step 1 earlier. + +Rows of a specific dynamic result type can therefore be selected with the +following CSS selector, where ``TYPE_NAME`` is the name of the type: + +.. code-block:: css + + .urlbarView-row[dynamicType=TYPE_NAME] + +Child Element Class +................... + +As discussed in `View Templates`_, each object in the view template can have a +``name`` property. The elements in the view corresponding to the objects in the +view template receive a class named +``urlbarView-dynamic-TYPE_NAME-ELEMENT_NAME``, where ``TYPE_NAME`` is the name +of the dynamic result type, and ``ELEMENT_NAME`` is the name of the object in +the view template. + +Elements in dynamic result type rows can therefore be selected with the +following: + +.. code-block:: css + + .urlbarView-dynamic-TYPE_NAME-ELEMENT_NAME + +If an object in the view template does not have a ``name`` property, then it +won't receive the class and it therefore can't be selected using this selector. + +View Templates +-------------- + +A **view template** is a plain JS object that declaratively describes how to +build the DOM for a dynamic result type. When a result of a particular dynamic +result type is shown in the view, the type's view template is used to construct +the part of the view that represents the type in general. + +The need for view templates arises from the fact that extensions run in a +separate process from the chrome process and can't directly access the chrome +DOM, where the address bar view lives. Since extensions are a primary use case +for dynamic result types, this is an important constraint on their design. + +Properties +~~~~~~~~~~ + +A view template object is a tree-like nested structure where each object in the +nesting represents a DOM element to be created. This tree-like structure is +achieved using the ``children`` property described below. Each object in the +structure may include the following properties: + +``{string} name`` + The name of the object. This is required for all objects in the structure + except the root object and serves two important functions: + + 1. The element created for the object will automatically have a class named + ``urlbarView-dynamic-${dynamicType}-${name}``, where ``dynamicType`` is the + name of the dynamic result type. The element will also automatically have + an attribute ``name`` whose value is this name. The class and attribute + allow the element to be styled in CSS. + + 2. The name is used when updating the view, as described in `View Update + Objects`_. + + Names must be unique within a view template, but they don't need to be + globally unique. In other words, two different view templates can use the same + names, and other unrelated DOM elements can use the same names in their IDs + and classes. + +``{string} tag`` + The element tag name of the object. This is required for all objects in the + structure except the root object and declares the kind of element that will be + created for the object: ``span``, ``div``, ``img``, etc. + +``{object} [attributes]`` + An optional mapping from attribute names to values. For each name-value pair, + an attribute is set on the element created for the object. + + A special ``selectable`` attribute tells the view that the element is + selectable with the keyboard. The element will automatically participate in + the view's keyboard selection behavior. + + Similarly, the ``role=button`` ARIA attribute will also automatically allow + the element to participate in keyboard selection. The ``selectable`` attribute + is not necessary when ``role=button`` is specified. + +``{array} [children]`` + An optional list of children. Each item in the array must be an object as + described in this section. For each item, a child element as described by the + item is created and added to the element created for the parent object. + +``{array} [classList]`` + An optional list of classes. Each class will be added to the element created + for the object by calling ``element.classList.add()``. + +``{string} [stylesheet]`` + For dynamic result types created in extensions, this property should be set on + the root object in the view template structure, and its value should be a + stylesheet URL. The stylesheet will be loaded in all browser windows so that + the dynamic result type view may be styled. The specified URL will be resolved + against the extension's base URI. We recommend specifying a URL relative to + your extension's base directory. + + For dynamic result types created internally in the address bar codebase, this + value should not be specified and instead styling should be added to + `urlbar-dynamic-results.css`_. + +Example +~~~~~~~ + +Let's return to the weather forecast example from `earlier <Motivation_>`__. For +each result of our weather forecast dynamic result type, we might want to +display a label for a city name along with two buttons for today's and +tomorrow's forecasted high and low temperatures. The view template might look +like this: + +.. code-block:: javascript + + { + stylesheet: "style.css", + children: [ + { + name: "cityLabel", + tag: "span", + }, + { + name: "today", + tag: "div", + classList: ["day"], + attributes: { + selectable: true, + }, + children: [ + { + name: "todayLabel", + tag: "span", + classList: ["dayLabel"], + }, + { + name: "todayLow", + tag: "span", + classList: ["temperature", "temperatureLow"], + }, + { + name: "todayHigh", + tag: "span", + classList: ["temperature", "temperatureHigh"], + }, + }, + }, + { + name: "tomorrow", + tag: "div", + classList: ["day"], + attributes: { + selectable: true, + }, + children: [ + { + name: "tomorrowLabel", + tag: "span", + classList: ["dayLabel"], + }, + { + name: "tomorrowLow", + tag: "span", + classList: ["temperature", "temperatureLow"], + }, + { + name: "tomorrowHigh", + tag: "span", + classList: ["temperature", "temperatureHigh"], + }, + }, + }, + ], + } + +Observe that we set the special ``selectable`` attribute on the ``today`` and +``tomorrow`` elements so they can be selected with the keyboard. + +View Update Objects +------------------- + +A **view update object** is a plain JS object that declaratively describes how +to update the DOM for a specific result of a dynamic result type. When a result +of a dynamic result type is shown in the view, a view update object is requested +from the result's provider and is used to update the DOM for that result. + +Note the difference between view update objects, described in this section, and +view templates, described in the previous section. View templates are used to +build a general DOM structure appropriate for all results of a particular +dynamic result type. View update objects are used to fill in that structure for +a specific result. + +When a result is shown in the view, first the view looks up the view template of +the result's dynamic result type. It uses the view template to build a DOM +subtree. Next, the view requests a view update object for the result from its +provider. The view update object tells the view which result-specific attributes +to set on which elements, result-specific text content to set on elements, and +so on. View update objects cannot create new elements or otherwise modify the +structure of the result's DOM subtree. + +Typically the view update object is based on the result's payload. + +Properties +~~~~~~~~~~ + +The view update object is a nested structure with two levels. It looks like +this: + +.. code-block:: javascript + + { + name1: { + // individual update object for name1 + }, + name2: { + // individual update object for name2 + }, + name3: { + // individual update object for name3 + }, + // ... + } + +The top level maps object names from the view template to individual update +objects. The individual update objects tell the view how to update the elements +with the specified names. If a particular element doesn't need to be updated, +then it doesn't need an entry in the view update object. + +Each individual update object can have the following properties: + +``{object} [attributes]`` + A mapping from attribute names to values. Each name-value pair results in an + attribute being set on the element. + +``{object} [style]`` + A plain object that can be used to add inline styles to the element, like + ``display: none``. ``element.style`` is updated for each name-value pair in + this object. + +``{object} [l10n]`` + An ``{ id, args }`` object that will be passed to + ``document.l10n.setAttributes()``. + +``{string} [textContent]`` + A string that will be set as ``element.textContent``. + +Example +~~~~~~~ + +Continuing our weather forecast example, the view update object needs to update +several things that we declared in our view template: + +* The city label +* The "today" label +* Today's low and high temperatures +* The "tomorrow" label +* Tomorrow's low and high temperatures + +Typically, each of these, with the possible exceptions of the "today" and +"tomorrow" labels, would come from our results' payloads. There's an important +connection between what's in the view and what's in the payloads: The data in +the payloads serves the information shown in the view. + +Our view update object would then look something like this: + +.. code-block:: javascript + + { + cityLabel: { + textContent: result.payload.city, + }, + todayLabel: { + textContent: "Today", + }, + todayLow: { + textContent: result.payload.todayLow, + }, + todayHigh: { + textContent: result.payload.todayHigh, + }, + tomorrowLabel: { + textContent: "Tomorrow", + }, + tomorrowLow: { + textContent: result.payload.tomorrowLow, + }, + tomorrowHigh: { + textContent: result.payload.tomorrowHigh, + }, + } + +Accessibility +------------- + +Just like built-in types, dynamic result types support a11y in the view, and you +should make sure your view implementation is fully accessible. + +Since the views for dynamic result types are implemented using view templates +and view update objects, in practice supporting a11y for dynamic result types +means including appropriate `ARIA attributes <aria_>`_ in the view template and +view update objects, using the ``attributes`` property. + +Many ARIA attributes depend on element IDs, and that's why the ``idsByName`` +parameter to the ``getViewUpdate`` provider method is useful. + +Usually, accessible address bar results require the ARIA attribute +``role=group`` on their top-level DOM element to indicate that all the child +elements in the result's DOM subtree form a logical group. This attribute can be +set on the root object in the view template. + +.. _aria: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA + +Example +~~~~~~~ + +Continuing the weather forecast example, we'd like for screen readers to know +that our result is labeled by the city label so that they announce the city when +the result is selected. + +The relevant ARIA attribute is ``aria-labelledby``, and its value is the ID of +the element with the label. In our ``getViewUpdate`` implementation, we can use +the ``idsByName`` map to get the element ID that the view created for our city +label, like this: + +.. code-block:: javascript + + getViewUpdate(result, idsByName) { + return { + root: { + attributes: { + "aria-labelledby": idsByName.get("cityLabel"), + }, + }, + // *snipping the view update object example from earlier* + }; + } + +Here we're using the name "root" to refer to the root object in the view +template, so we also need to update our view template by adding the ``name`` +property to the top-level object, like this: + +.. code-block:: javascript + + { + stylesheet: "style.css", + name: "root", + attributes: { + role: "group", + }, + children: [ + { + name: "cityLabel", + tag: "span", + }, + // *snipping the view template example from earlier* + ], + } + +Note that we've also included the ``role=group`` ARIA attribute on the root, as +discussed above. We could have included it in the view update object instead of +the view template, but since it doesn't depend on a specific result or element +ID in the ``idsByName`` map, the view template makes more sense. + +Mimicking Built-in Address Bar Results +-------------------------------------- + +Sometimes it's desirable to create a new result type that looks and behaves like +the usual built-in address bar results. Two conveniences are available that are +useful in this case. + +URL Navigation +~~~~~~~~~~~~~~ + +If a result's payload includes a string ``url`` property and a boolean +``shouldNavigate: true`` property, then picking the result will navigate to the +URL. The ``onEngagement`` method of the result's provider will still be called +before navigation. + +Text Highlighting +~~~~~~~~~~~~~~~~~ + +Most built-in address bar results emphasize occurrences of the user's search +string in their text by boldfacing matching substrings. Search suggestion +results do the opposite by emphasizing the portion of the suggestion that the +user has not yet typed. This emphasis feature is called **highlighting**, and +it's also available to the results of dynamic result types. + +Highlighting for dynamic result types is a fairly automated process. The text +that you want to highlight must be present as a property in your result +payload. Instead of setting the property to a string value as you normally +would, set it to an array with two elements, where the first element is the text +and the second element is a ``UrlbarUtils.HIGHLIGHT`` value, like the ``title`` +payload property in the following example: + +.. code-block:: javascript + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + { + title: [ + "Some result title", + UrlbarUtils.HIGHLIGHT.TYPED, + ], + // *more payload properties* + } + ); + +``UrlbarUtils.HIGHLIGHT`` is defined in the extensions shim_ and is described +below. + +Your view template must create an element corresponding to the payload +property. That is, it must include an object where the value of the ``name`` +property is the name of the payload property, like this: + +.. code-block:: javascript + + { + children: [ + { + name: "title", + tag: "span", + }, + // ... + ], + } + +In contrast, your view update objects must *not* include an update for the +element. That is, they must not include a property whose name is the name of the +payload property. + +Instead, when the view is ready to update the DOM of your results, it will +automatically find the elements corresponding to the payload property, set their +``textContent`` to the text value in the array, and apply the appropriate +highlighting, as described next. + +There are two possible ``UrlbarUtils.HIGHLIGHT`` values. Each controls how +highlighting is performed: + +``UrlbarUtils.HIGHLIGHT.TYPED`` + Substrings in the payload text that match the user's search string will be + emphasized. + +``UrlbarUtils.HIGHLIGHT.SUGGESTED`` + If the user's search string appears in the payload text, then the remainder of + the text following the matching substring will be emphasized. + +Appendix A: Examples +-------------------- + +This section lists some example and real-world consumers of dynamic result +types. + +`Example Extension`__ + This extension demonstrates a simple use of dynamic result types. + +`Weather Quick Suggest Extension`__ + A real-world Firefox extension experiment that shows weather forecasts and + alerts when the user performs relevant searches in the address bar. + +`Tab-to-Search Provider`__ + This is a built-in provider in mozilla-central that uses dynamic result types. + +__ https://github.com/0c0w3/dynamic-result-type-extension +__ https://github.com/mozilla-extensions/firefox-quick-suggest-weather/blob/master/src/background.js +__ https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarProviderTabToSearch.sys.mjs + +Appendix B: Using the WebExtensions API Directly +------------------------------------------------ + +If you're developing an extension, the recommended way of using dynamic result +types is to use the shim_, which abstracts away the differences between writing +internal address bar code and extensions code. The `implementation steps`_ above +apply to extensions as long as you're using the shim. + +For completeness, in this section we'll document the WebExtensions APIs that the +shim is built on. If you don't use the shim for some reason, then follow these +steps instead. You'll see that each step above using the shim has an analogous +step here. + +The WebExtensions API schema is declared in `schema.json`_ and implemented in +`api.js`_. + +.. _schema.json: https://github.com/0c0w3/dynamic-result-type-extension/blob/master/src/experiments/urlbar/schema.json +.. _api.js: https://github.com/0c0w3/dynamic-result-type-extension/blob/master/src/experiments/urlbar/api.js + +1. Register the dynamic result type +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +First, register the new dynamic result type: + +.. code-block:: javascript + + browser.experiments.urlbar.addDynamicResultType(name, type); + +``name`` is a string identifier for the new type. See step 1 in `Implementation +Steps`_ for a description, which applies here, too. + +``type`` is an object with metadata for the new type. Currently no metadata is +supported, so this should be an empty object, which is the default value. + +2. Register the view template +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Next, add the view template for the new type: + +.. code-block:: javascript + + browser.experiments.urlbar.addDynamicViewTemplate(name, viewTemplate); + +See step 2 above for a description of the parameters. + +3. Add WebExtension event listeners +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add all the WebExtension event listeners you normally would in an address bar +extension, including the two required listeners, ``onBehaviorRequested`` and +and ``onResultsRequested``. + +.. code-block:: javascript + + browser.urlbar.onBehaviorRequested.addListener(query => { + return "active"; + }, providerName); + + browser.urlbar.onResultsRequested.addListener(query => { + let results = [ + // ... + ]; + return results; + }, providerName); + +See the address bar extensions__ document for help on the urlbar WebExtensions +API. + +__ https://firefox-source-docs.mozilla.org/browser/urlbar/experiments.html + +4. Add an onViewUpdateRequested event listener +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``onViewUpdateRequested`` is a WebExtensions event particular to dynamic result +types. It's analogous to the ``getViewUpdate`` provider method described +earlier. + +.. code-block:: javascript + + browser.experiments.urlbar.onViewUpdateRequested.addListener((payload, idsByName) => { + let viewUpdate = { + // ... + }; + return viewUpdate; + }); + +Note that unlike ``getViewUpdate``, here the listener's first parameter is a +result payload, not the result itself. + +The listener should return a view update object. + +5. Style the results +~~~~~~~~~~~~~~~~~~~~ + +This step is the same as step 5 above. Bundle a CSS file in your extension and +declare it in the top-level ``stylesheet`` property of your view template. diff --git a/browser/components/urlbar/docs/experiments.rst b/browser/components/urlbar/docs/experiments.rst new file mode 100644 index 0000000000..b4fabc8f7a --- /dev/null +++ b/browser/components/urlbar/docs/experiments.rst @@ -0,0 +1,726 @@ +Extensions & Experiments +======================== + +This document describes address bar extensions and experiments: what they are, +how to run them, how to write them, and the processes involved in each. + +The primary purpose right now for writing address bar extensions is to run +address bar experiments. But extensions are useful outside of experiments, and +not all experiments use extensions. + +Like all Firefox extensions, address bar extensions use the WebExtensions_ +framework. + +.. _WebExtensions: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions + +.. contents:: + :depth: 2 + + +WebExtensions +------------- + +**WebExtensions** is the name of Firefox's extension architecture. The "web" +part of the name hints at the fact that Firefox extensions are built using Web +technologies: JavaScript, HTML, CSS, and to a certain extent the DOM. + +Individual extensions themselves often are referred to as *WebExtensions*. For +clarity and conciseness, this document will refer to WebExtensions as +*extensions*. + +Why are we interested in extensions? Mainly because they're a powerful way to +run experiments in Firefox. See Experiments_ for more on that. In addition, we'd +also like to build up a robust set of APIs useful to extension authors, although +right now the API can only be used by Mozilla extensions. + +WebExtensions are introduced and discussed in detail on `MDN +<WebExtensions_>`__. You'll need a lot of that knowledge in order to build +address bar extensions. + +Developing Address Bar Extensions +--------------------------------- + +Overview +~~~~~~~~ + +The address bar WebExtensions API currently lives in two API namespaces, +``browser.urlbar`` and ``browser.experiments.urlbar``. The reason for this is +historical and is discussed in the `Developing Address Bar Extension APIs`_ +section. As a consumer of the API, there are only two important things you need +to know: + +* There's no meaningful difference between the APIs of the two namespaces. + Their kinds of functions, events, and properties are similar. You should + think of the address bar API as one single API that happens to be split into + two namespaces. + +* However, there is a big difference between the two when it comes to setting up + your extension to use them. This is discussed next. + +The ``browser.urlbar`` API namespace is built into Firefox. It's a +**privileged API**, which means that only Mozilla-signed and temporarily +installed extensions can use it. The only thing your Mozilla extension needs to +do in order to use it is to request the ``urlbar`` permission in its +manifest.json, as illustrated `here <urlbarPermissionExample_>`__. + +In contrast, the ``browser.experiments.urlbar`` API namespace is bundled inside +your extension. APIs that are bundled inside extensions are called +**experimental APIs**, and the extensions in which they're bundled are called +**WebExtension experiments**. As with privileged APIs, experimental APIs are +available only to Mozilla-signed and temporarily installed extensions. +("WebExtension experiments" is a term of art and shouldn't be confused with the +general notion of experiments that happen to use extensions.) For more on +experimental APIs and WebExtension experiments, see the `WebExtensions API +implementation documentation <webextAPIImplBasicsDoc_>`__. + +Since ``browser.experiments.urlbar`` is bundled inside your extension, you'll +need to include it in your extension's repo by doing the following: + +1. The implementation consists of two files, api.js_ and schema.json_. In your + extension repo, create a *experiments/urlbar* subdirectory and copy the + files there. See `this repo`__ for an example. + +2. Add the following ``experiment_apis`` key to your manifest.json (see here__ + for an example in context):: + + "experiment_apis": { + "experiments_urlbar": { + "schema": "experiments/urlbar/schema.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["experiments", "urlbar"]], + "script": "experiments/urlbar/api.js" + } + } + } + +As mentioned, only Mozilla-signed and temporarily installed extensions can use +these two API namespaces. For information on running the extensions you develop +that use these namespaces, see `Running Address Bar Extensions`_. + +.. _urlbarPermissionExample: https://github.com/0c0w3/urlbar-top-sites-experiment/blob/ac1517118bb7ee165fb9989834514b1082575c10/src/manifest.json#L24 +.. _webextAPIImplBasicsDoc: https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/basics.html +.. _api.js: https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/ext/api.js +.. _schema.json: https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/ext/schema.json +__ https://github.com/0c0w3/dynamic-result-type-extension/tree/master/src/experiments/urlbar +__ https://github.com/0c0w3/dynamic-result-type-extension/blob/0987da4b259b9fcb139b31d771883a2f822712b5/src/manifest.json#L28 + +browser.urlbar +~~~~~~~~~~~~~~ + +Currently the only documentation for ``browser.urlbar`` is its `schema +<urlbar.json_>`__. Fortunately WebExtension schemas are JSON and aren't too hard +to read. If you need help understanding it, see the `WebExtensions API +implementation documentation <webextAPIImplDoc_>`__. + +For examples on using the API, see the Cookbook_ section. + +.. _urlbar.json: https://searchfox.org/mozilla-central/source/browser/components/extensions/schemas/urlbar.json + +browser.experiments.urlbar +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As with ``browser.urlbar``, currently the only documentation for +``browser.experiments.urlbar`` is its schema__. For examples on using the API, +see the Cookbook_ section. + +__ https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/ext/schema.json + +Workflow +~~~~~~~~ + +The web-ext_ command-line tool makes the extension-development workflow very +simple. Simply start it with the *run* command, passing it the location of the +Firefox binary you want to use. web-ext will launch your Firefox and remain +running until you stop it, watching for changes you make to your extension's +files. When it sees a change, it automatically reloads your extension — in +Firefox, in the background — without your having to do anything. It's really +nice. + +The `web-ext documentation <web-ext commands_>`__ lists all its options, but +here are some worth calling out for the *run* command: + +``--browser-console`` + Automatically open the browser console when Firefox starts. Very useful for + watching your extension's console logging. (Make sure "Show Content Messages" + is checked in the console.) + +``-p`` + This option lets you specify a path to a profile directory. + +``--keep-profile-changes`` + Normally web-ext doesn't save any changes you make to the profile. Use this + option along with ``-p`` to reuse the same profile again and again. + +``--verbose`` + web-ext suppresses Firefox messages in the terminal unless you pass this + option. If you've added some ``dump`` calls in Firefox because you're working + on a new ``browser.urlbar`` API, for example, you won't see them without this. + +web-ext also has a *build* command that packages your extension's files into a +zip file. The following *build* options are useful: + +``--overwrite-dest`` + Without this option, web-ext won't overwrite a zip file it previously created. + +web-ext can load its configuration from your extension's package.json. That's +the recommended way to configure it. Here's an example__. + +Finally, web-ext can also sign extensions, but if you're developing your +extension for an experiment, you'll use a different process for signing. See +`The Experiment Development Process`_. + +.. _web-ext: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Getting_started_with_web-ext +.. _web-ext commands: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/web-ext_command_reference +__ https://github.com/0c0w3/urlbar-top-sites-experiment/blob/6681a7126986bc2565d036b888cb5b8807397ce5/package.json#L7 + +Automated Tests +~~~~~~~~~~~~~~~ + +It's possible to write `browser chrome mochitests`_ for your extension the same +way we write tests for Firefox. One of the example extensions linked throughout +this document includes a test_, for instance. + +See the readme in the example-addon-experiment_ repo for a workflow. + +.. _browser chrome mochitests: https://developer.mozilla.org/en-US/docs/Mozilla/Browser_chrome_tests +.. _test: https://github.com/0c0w3/urlbar-top-sites-experiment/blob/master/tests/tests/browser/browser_urlbarTopSitesExtension.js + +Cookbook +~~~~~~~~ + +*To be written.* For now, you can find example uses of ``browser.experiments.urlbar`` and ``browser.urlbar`` in the following repos: + +* https://github.com/mozilla-extensions/firefox-quick-suggest-weather +* https://github.com/0c0w3/urlbar-tips-experiment +* https://github.com/0c0w3/urlbar-top-sites-experiment +* https://github.com/0c0w3/urlbar-search-interventions-experiment + +Further Reading +~~~~~~~~~~~~~~~ + +`WebExtensions on MDN <WebExtensions_>`__ + The place to learn about developing WebExtensions in general. + +`Getting started with web-ext <web-ext_>`__ + MDN's tutorial on using web-ext. + +`web-ext command reference <web-ext commands_>`__ + MDN's documentation on web-ext's commands and their options. + +Developing Address Bar Extension APIs +------------------------------------- + +Built-In APIs vs. Experimental APIs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Originally we developed the address bar extension API in the ``browser.urlbar`` +namespace, which is built into Firefox as discussed above. By "built into +Firefox," we mean that the API is developed in `mozilla-central +<urlbar.json_>`__ and shipped inside Firefox just like any other Firefox +feature. At the time, that seemed like the right thing to do because we wanted +to build an API that ultimately could be used by all extension authors, not only +Mozilla. + +However, there were a number of disadvantages to this development model. The +biggest was that it tightly coupled our experiments to specific versions of +Firefox. For example, if we were working on an experiment that targeted Firefox +72, then any APIs used by that experiment needed to land and ship in 72. If we +weren't able to finish an API by the time 72 shipped, then the experiment would +have to be postponed until 73. Our experiment development timeframes were always +very short because we always wanted to ship our experiments ASAP. Often we +targeted the Firefox version that was then in Nightly; sometimes we even +targeted the version in Beta. Either way, it meant that we were always uplifting +patch after patch to Beta. This tight coupling between Firefox versions and +experiments erased what should have been a big advantage of implementing +experiments as extensions in the first place: the ability to ship experiments +outside the usual cyclical release process. + +Another notable disadvantage of this model was just the cognitive weight of the +idea that we were developing APIs not only for ourselves and our experiments but +potentially for all extensions. This meant that not only did we have to design +APIs to meet our immediate needs, we also had to imagine use cases that could +potentially arise and then design for them as well. + +For these reasons, we stopped developing ``browser.urlbar`` and created the +``browser.experiments.urlbar`` experimental API. As discussed earlier, +experimental APIs are APIs that are bundled inside extensions. Experimental APIs +can do anything that built-in APIs can do with the added flexibility of not +being tied to specific versions of Firefox. + +Adding New APIs +~~~~~~~~~~~~~~~ + +All new address bar APIs should be added to ``browser.experiments.urlbar``. +Although this API does not ship in Firefox, it's currently developed in +mozilla-central, in `browser/components/urlbar/tests/ext/ <extDirectory_>`__ -- +note the "tests" subdirectory. Developing it in mozilla-central lets us take +advantage of our usual build and testing infrastructure. This way we have API +tests running against each mozilla-central checkin, against all versions of +Firefox that are tested on Mozilla's infrastructure, and we're alerted to any +breaking changes we accidentally make. When we start a new extension repo, we +copy schema.json and api.js to it as described earlier (or clone an example repo +with up-to-date copies of these files). + +Generally changes to the API should be reviewed by someone on the address bar +team and someone on the WebExtensions team. Shane (mixedpuppy) is a good +contact. + +.. _extDirectory: https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/ext/ + +Anatomy of an API +~~~~~~~~~~~~~~~~~ + +Roughly speaking, a WebExtensions API implementation comprises three different +pieces: + +Schema + The schema declares the functions, properties, events, and types that the API + makes available to extensions. Schemas are written in JSON. + + The ``browser.experiments.urlbar`` schema is schema.json_, and the + ``browser.urlbar`` schema is urlbar.json_. + + For reference, the schemas of built-in APIs are in + `browser/components/extensions/schemas`_ and + `toolkit/components/extensions/schemas`_. + + .. _browser/components/extensions/schemas: https://searchfox.org/mozilla-central/source/browser/components/extensions/schemas/ + .. _toolkit/components/extensions/schemas: https://searchfox.org/mozilla-central/source/toolkit/components/extensions/schemas/ + +Internals + Every API hooks into some internal part of Firefox. For the address bar API, + that's the Urlbar implementation in `browser/components/urlbar`_. + + .. _browser/components/urlbar: https://searchfox.org/mozilla-central/source/browser/components/urlbar/ + +Glue + Finally, there's some glue code that implements everything declared in the + schema. Essentially, this code mediates between the previous two pieces. It + translates the function calls, property accesses, and event listener + registrations made by extensions using the public-facing API into terms that + the Firefox internals understand, and vice versa. + + For ``browser.experiments.urlbar``, this is api.js_, and for + ``browser.urlbar``, it's ext-urlbar.js_. + + For reference, the implementations of built-in APIs are in + `browser/components/extensions`_ and `toolkit/components/extensions`_, in the + *parent* and *child* subdirecties. As you might guess, code in *parent* runs + in the main process, and code in *child* runs in the extensions process. + Address bar APIs deal with browser chrome and their implementations therefore + run in the parent process. + + .. _ext-urlbar.js: https://searchfox.org/mozilla-central/source/browser/components/extensions/parent/ext-urlbar.js + .. _browser/components/extensions: https://searchfox.org/mozilla-central/source/browser/components/extensions/ + .. _toolkit/components/extensions: https://searchfox.org/mozilla-central/source/toolkit/components/extensions/ + +Keep in mind that extensions run in a different process from the main process. +That has implications for your APIs. They'll generally need to be async, for +example. + +Further Reading +~~~~~~~~~~~~~~~ + +`WebExtensions API implementation documentation <webextAPIImplDoc_>`__ + Detailed info on implementing a WebExtensions API. + +.. _webextAPIImplDoc: https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/ + +Running Address Bar Extensions +------------------------------ + +As discussed above, ``browser.experiments.urlbar`` and ``browser.urlbar`` are +privileged APIs. There are two different points to consider when it comes to +running an extension that uses privileged APIs: loading the extension in the +first place, and granting it access to privileged APIs. There's a certain bar +for loading any extension regardless of its API usage that depends on its signed +state and the Firefox build you want to run it in. There's yet a higher bar for +granting it access to privileged APIs. This section discusses how to load +extensions so that they can access privileged APIs. + +Since we're interested in extensions primarily for running experiments, there +are three particular signed states relevant to us: + +Unsigned + There are two ways to run unsigned extensions that use privileged APIs. + + They can be loaded temporarily using a Firefox Nightly build or + Developer Edition but not Beta or Release [source__], and the + ``extensions.experiments.enabled`` preference must be set to true [source__]. + You can load extensions temporarily by visiting + about:debugging#/runtime/this-firefox and clicking "Load Temporary Add-on." + `web-ext <Workflow_>`__ also loads extensions temporarily. + + __ https://searchfox.org/mozilla-central/rev/053826b10f838f77c27507e5efecc96e34718541/toolkit/components/extensions/Extension.jsm#1884 + __ https://searchfox.org/mozilla-central/rev/014fe72eaba26dcf6082fb9bbaf208f97a38594e/toolkit/mozapps/extensions/internal/AddonSettings.jsm#93 + + They can be also be loaded normally (not temporarily) in a custom build where + the build-time setting ``AppConstants.MOZ_REQUIRE_SIGNING`` [source__, source__] + and ``xpinstall.signatures.required`` pref are both false. As in the previous + paragraph, such builds include Nightly and Developer Edition but not Beta or + Release [source__]. In addition, your custom build must modify the + ``Extension.isPrivileged`` getter__ to return true. This getter determines + whether an extension can access privileged APIs. + + __ https://searchfox.org/mozilla-central/rev/053826b10f838f77c27507e5efecc96e34718541/toolkit/mozapps/extensions/internal/XPIProvider.jsm#2382 + __ https://searchfox.org/mozilla-central/rev/053826b10f838f77c27507e5efecc96e34718541/toolkit/mozapps/extensions/internal/AddonSettings.jsm#36 + __ https://searchfox.org/mozilla-central/search?q=MOZ_REQUIRE_SIGNING&case=false®exp=false&path= + __ https://searchfox.org/mozilla-central/rev/053826b10f838f77c27507e5efecc96e34718541/toolkit/components/extensions/Extension.jsm#1874 + + Extensions remain unsigned as you develop them. See the Workflow_ section for + more. + +Signed for testing (Signed for QA) + Signed-for-testing extensions that use privileged APIs can be run using the + same techniques for running unsigned extensions. + + They can also be loaded normally (not temporarily) if you use a Firefox build + where the build-time setting ``AppConstants.MOZ_REQUIRE_SIGNING`` is false and + you set the ``xpinstall.signatures.dev-root`` pref to true + [source__]. ``xpinstall.signatures.dev-root`` does not exist by default and + must be created. + + __ https://searchfox.org/mozilla-central/rev/053826b10f838f77c27507e5efecc96e34718541/toolkit/mozapps/extensions/internal/XPIInstall.jsm#262 + + You encounter extensions that are signed for testing when you are writing + extensions for experiments. See the Experiments_ section for details. + + "Signed for QA" is another way of referring to this signed state. + +Signed for release + Signed-for-release extensions that use privileged APIs can be run in any + Firefox build with no special requirements. + + You encounter extensions that are signed for release when you are writing + extensions for experiments. See the Experiments_ section for details. + +.. important:: + To see console logs from extensions in the browser console, select the "Show + Content Messages" option in the console's settings. This is necessary because + extensions run outside the main process. + +Experiments +----------- + +**Experiments** let us try out ideas in Firefox outside the usual release cycle +and on particular populations of users. + +For example, say we have a hunch that the top sites shown on the new-tab page +aren't very discoverable, so we want to make them more visible. We have one idea +that might work — show them every time the user begins an interaction with the +address bar — but we aren't sure how good an idea it is. So we test it. We write +an extension that does just that, make sure it collects telemetry that will help +us answer our question, ship it outside the usual release cycle to a small +percentage of Beta users, collect and analyze the telemetry, and determine +whether the experiment was successful. If it was, then we might want to ship the +feature to all Firefox users. + +Experiments sometimes are also called **studies** (not to be confused with *user +studies*, which are face-to-face interviews with users conducted by user +researchers). + +There are two types of experiments: + +Pref-flip experiments + Pref-flip experiments are simple. If we have a fully baked feature in the + browser that's preffed off, a pref-flip experiment just flips the pref on, + enabling the feature for users running the experiment. No code is required. + We tell the experiments team the name of the pref we want to flip, and they + handle it. + + One important caveat to pref-flip studies is that they're currently capable of + flipping only a single pref. There's an extension called Multipreffer_ that + can flip multiple prefs, though. + + .. _Multipreffer: https://github.com/mozilla/multipreffer + +Add-on experiments + Add-on experiments are much more complex but much more powerful. (Here + *add-on* is a synonym for extension.) They're the type of experiments that + this document has been discussing all along. + + An add-on experiment is shipped as an extension that we write and that + implements the experimental feature we want to test. To reiterate, the + extension is a WebExtension and uses WebExtensions APIs. If the current + WebExtensions APIs do not meet the needs of your experiment, then you must + create either experimental or built-in APIs so that your extension can use + them. If necessary, you can make any new built-in APIs privileged so that they + are available only to Mozilla extensions. + + An add-on experiment can collect additional telemetry that's not collected in + the product by using the privileged ``browser.telemetry`` WebExtensions API, + and of course the product will continue to collect all the telemetry it + usually does. The telemetry pings from users running the experiment will be + correlated with the experiment with no extra work on our part. + +A single experiment can deliver different UXes to different groups of users +running the experiment. Each group or UX within an experiment is called a +**branch**. Experiments often have two branches, control and treatment. The +**control branch** actually makes no UX changes. It may capture additional +telemetry, though. Think of it as the control in a science experiment. It's +there so we can compare it to data from the **treatment branch**, which does +make UX changes. Some experiments may require multiple treatment branches, in +which case the different branches will have different names. Add-on experiments +can implement all branches in the same extension or each branch in its own +extension. + +Experiments are delivered to users by a system called **Normandy**. Normandy +comprises a client side that lives in Firefox and a server side. In Normandy, +experiments are defined server-side in files called **recipes**. Recipes include +information about the experiment like the Firefox release channel and version +that the experiment targets, the number of users to be included in the +experiment, the branches in the experiment, the percentage of users on each +branch, and so on. + +Experiments are tracked by Mozilla project management using a system called +Experimenter_. + +Finally, there was an older version of the experiments program called +**Shield**. Experiments under this system were called **Shield studies** and +could be be shipped as extensions too. + +.. _Experimenter: https://experimenter.services.mozilla.com/ + +Further Reading +~~~~~~~~~~~~~~~ + +`Pref-Flip and Add-On Experiments <https://mana.mozilla.org/wiki/pages/viewpage.action?spaceKey=FIREFOX&title=Pref-Flip+and+Add-On+Experiments>`__ + A comprehensive document on experiments from the Experimenter team. See the + child pages in the sidebar, too. + +`Client Implementation Guidelines for Experiments <https://docs.telemetry.mozilla.org/cookbooks/client_guidelines.html>`_ + Relevant documentation from the telemetry team. + +#ask-experimenter Slack channel + A friendly place to get answers to your experiment questions. + +The Experiment Development Process +---------------------------------- + +This section describes an experiment's life cycle. + +1. Experiments usually originate with product management and UX. They're + responsible for identifying a problem, deciding how an experiment should + approach it, the questions we want to answer, the data we need to answer + those questions, the user population that should be enrolled in the + experiment, the definition of success, and so on. + +2. UX makes a spec that describes what the extension looks like and how it + behaves. + +3. There's a kickoff meeting among the team to introduce the experiment and UX + spec. It's an opportunity for engineering to ask questions of management, UX, + and data science. It's really important for engineering to get a precise and + accurate understanding of how the extension is supposed to behave — right + down to the UI changes — so that no one makes erroneous assumptions during + development. + +4. At some point around this time, the team (usually management) creates a few + artifacts to track the work and facilitate communication with outside teams + involved in shipping experiments. They include: + + * A page on `Experimenter <Experiments_>`__ + * A QA PI (product integrity) request so that QA resources are allocated + * A bug in `Data Science :: Experiment Collaboration`__ so that data science + can track the work and discuss telemetry (engineering might file this one) + + __ https://bugzilla.mozilla.org/enter_bug.cgi?assigned_to=nobody%40mozilla.org&bug_ignored=0&bug_severity=normal&bug_status=NEW&bug_type=task&cf_firefox_messaging_system=---&cf_fx_iteration=---&cf_fx_points=---&comment=%23%23%20Brief%20Description%20of%20the%20request%20%28required%29%3A%0D%0A%0D%0A%23%23%20Business%20purpose%20for%20this%20request%20%28required%29%3A%0D%0A%0D%0A%23%23%20Requested%20timelines%20for%20the%20request%20or%20how%20this%20fits%20into%20roadmaps%20or%20critical%20decisions%20%28required%29%3A%0D%0A%0D%0A%23%23%20Links%20to%20any%20assets%20%28e.g%20Start%20of%20a%20PHD%2C%20BRD%3B%20any%20document%20that%20helps%20describe%20the%20project%29%3A%0D%0A%0D%0A%23%23%20Name%20of%20Data%20Scientist%20%28If%20Applicable%29%3A%0D%0A%0D%0A%2APlease%20note%20if%20it%20is%20found%20that%20not%20enough%20information%20has%20been%20given%20this%20will%20delay%20the%20triage%20of%20this%20request.%2A&component=Experiment%20Collaboration&contenttypemethod=list&contenttypeselection=text%2Fplain&filed_via=standard_form&flag_type-4=X&flag_type-607=X&flag_type-800=X&flag_type-803=X&flag_type-936=X&form_name=enter_bug&maketemplate=Remember%20values%20as%20bookmarkable%20template&op_sys=Unspecified&priority=--&product=Data%20Science&rep_platform=Unspecified&target_milestone=---&version=unspecified + +5. Engineering breaks down the work and files bugs. There's another engineering + meeting to discuss the breakdown, or it's discussed asynchronously. + +6. Engineering sets up a GitHub repo for the extension. See `Implementing + Experiments`_ for an example repo you can clone to get started. Disable + GitHub Issues on the repo so that QA will file bugs in Bugzilla instead of + GitHub. There's nothing wrong with GitHub Issues, but our team's project + management tracks all work through Bugzilla. If it's not there, it's not + captured. + +7. Engineering or management fills out the Add-on section of the Experimenter + page as much as possible at this point. "Active Experiment Name" isn't + necessary, and "Signed Release URL" won't be available until the end of the + process. + +8. Engineering implements the extension and any new WebExtensions APIs it + requires. + +9. When the extension is done, engineering or management clicks the "Ready for + Sign-Off" button on the Experimenter page. That changes the page's status + from "Draft" to "Ready for Sign-Off," which allows QA and other teams to sign + off on their portions of the experiment. + +10. Engineering requests the extension be signed "for testing" (or "for + QA"). Michael (mythmon) from the Experiments team and Rehan (rdalal) from + Services Engineering are good contacts. Build the extension zip file using + web-ext as discussed in Workflow_. Attach it to a bug (a metabug for + implementing the extension, for example), needinfo Michael or Rehan, and ask + him to sign it. He'll attach the signed version to the bug. If neither + Michael nor Rehan is available, try asking in the #ask-experimenter Slack + channel. + +11. Engineering sends QA the link to the signed extension and works with them to + resolve bugs they find. + +12. When QA signs off, engineering asks Michael to sign the extension "for + release" using the same needinfo process described earlier. + +13. Paste the URL of the signed extension in the "Signed Release URL" textbox of + the Add-on section of the Experimenter page. + +14. Other teams sign off as they're ready. + +15. The experiment ships! 🎉 + + +Implementing Experiments +------------------------ + +This section discusses how to implement add-on experiments. Pref-flip +experiments are much simpler and don't need a lot of explanation. You should be +familiar with the concepts discussed in the `Developing Address Bar Extensions`_ +and `Running Address Bar Extensions`_ sections before reading this one. + +The most salient thing about add-on experiments is that they're implemented +simply as privileged extensions. Other than being privileged and possibly +containing bundled experimental APIs, they're similar to all other extensions. + +The `top-sites experiment extension <topSites_>`__ is an example of a real, +shipped experiment. + +.. _topSites: https://github.com/0c0w3/urlbar-top-sites-experiment + +Setup +~~~~~ + +example-addon-experiment_ is a repo you can clone to get started. It's geared +toward urlbar extensions and includes the stub of a browser chrome mochitest. + +.. _example-addon-experiment: https://github.com/0c0w3/example-addon-experiment + +browser.normandyAddonStudy +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As discussed in Experiments_, an experiment typically has more than one branch +so that it can test different UXes. The experiment's extension(s) needs to know +the branch the user is enrolled in so that it can behave appropriately for the +branch: show the user the proper UX, collect the proper telemetry, and so on. + +This is the purpose of the ``browser.normandyAddonStudy`` WebExtensions API. +Like ``browser.urlbar``, it's a privileged API available only to Mozilla +extensions. + +Its schema is normandyAddonStudy.json_. + +It's a very simple API. The primary function is ``getStudy``, which returns the +study the user is currently enrolled in or null if there isn't one. (Recall that +*study* is a synonym for *experiment*.) One of the first things an experiment +extension typically does is to call this function. + +The Normandy client in Firefox will keep an experiment extension installed only +while the experiment is active. Therefore, ``getStudy`` should always return a +non-null study object. Nevertheless, the study object has an ``active`` boolean +property that's trivial to sanity check. (The example extension does.) + +The more important property is ``branch``, the name of the branch that the user +is enrolled in. Your extension should use it to determine the appropriate UX. + +Finally, there's an ``onUnenroll`` event that's fired when the user is +unenrolled in the study. It's not quite clear in what cases an extension would +need to listen for this event given that Normandy automatically uninstalls +extensions on unenrollment. Maybe if they create some persistent state that's +not automatically undone on uninstall by the WebExtensions framework? + +If your extension itself needs to unenroll the user for some reason, call +``endStudy``. + +.. _normandyAddonStudy.json: https://searchfox.org/mozilla-central/source/browser/components/extensions/schemas/normandyAddonStudy.json + +Telemetry +~~~~~~~~~ + +Experiments can capture telemetry in two places: in the product itself and +through the privileged ``browser.telemetry`` WebExtensions API. The API schema +is telemetry.json_. + +The telemetry pings from users running experiments are automatically correlated +with those experiments, no extra work required. That's true regardless of +whether the telemetry is captured in the product or though +``browser.telemetry``. + +The address bar has some in-product, preffed off telemetry that we want to +enable for all our experiments — at least that's the thinking as of August 2019. +It's called `engagement event telemetry`_, and it records user *engagements* +with and *abandonments* of the address bar [source__]. We added a +BrowserSetting_ on ``browser.urlbar`` just to let us flip the pref and enable +this telemetry in our experiment extensions. Call it like this:: + + await browser.urlbar.engagementTelemetry.set({ value: true }); + +.. _telemetry.json: https://searchfox.org/mozilla-central/source/toolkit/components/extensions/schemas/telemetry.json +.. _engagement event telemetry: https://bugzilla.mozilla.org/show_bug.cgi?id=1559136 +__ https://searchfox.org/mozilla-central/rev/7088fc958db5935eba24b413b1f16d6ab7bd13ea/browser/components/urlbar/UrlbarController.jsm#598 +.. _BrowserSetting: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/types/BrowserSetting + +Engineering Best Practices +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Clear up questions with your UX person early and often. There's often a gap +between what they have in their mind and what you have in yours. Nothing wrong +with that, it's just the nature of development. But misunderstandings can cause +big problems when they're discovered late. This is especially true of UX +behaviors, as opposed to visuals or styling. It's no fun to realize at the end +of a release cycle that you've designed the wrong WebExtensions API because some +UX detail was overlooked. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Related to the previous point, make builds of your extension for your UX person +so they can test it. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Taking the previous point even further, if your experiment will require a +substantial new API(s), you might think about prototyping the experiment +entirely in a custom Firefox build before designing the API at all. Give it to +your UX person. Let them disect it and tell you all the problems with it. Fill +in all the gaps in your understanding, and then design the API. We've never +actually done this, though. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It's a good idea to work on the extension as you're designing and developing the +APIs it'll use. You might even go as far as writing the first draft of the +extension before even starting to implement the APIs. That lets you spot +problems that may not be obvious were you to design the API in isolation. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Your extension's ID should end in ``@shield.mozilla.org``. QA will flag it if it +doesn't. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set ``"hidden": true`` in your extension's manifest.json. That hides it on +about:addons. (It can still be seen on about:studies.) QA will spot this if you +don't. + +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are drawbacks of hiding features behind prefs and enabling them in +experiment extensions. Consider not doing that if feasible, or at least weigh +these drawbacks against your expected benefits. + +* Prefs stay flipped on in private windows, but experiments often have special + requirements around private-browsing mode (PBM). Usually, they shouldn't be + active in PBM at all, unless of course the point of the experiment is to test + PBM. Extensions also must request PBM access ("incognito" in WebExtensions + terms), and the user can disable access at any time. The result is that part + of your experiment could remain enabled — the part behind the pref — while + other parts are disabled. + +* Prefs stay flipped on in safe mode, even though your extension (like all + extensions) will be disabled. This might be a bug__ in the WebExtensions + framework, though. + + __ https://bugzilla.mozilla.org/show_bug.cgi?id=1576997 diff --git a/browser/components/urlbar/docs/firefox-suggest-telemetry.rst b/browser/components/urlbar/docs/firefox-suggest-telemetry.rst new file mode 100644 index 0000000000..a692f45cfc --- /dev/null +++ b/browser/components/urlbar/docs/firefox-suggest-telemetry.rst @@ -0,0 +1,1533 @@ +Firefox Suggest Telemetry +========================= + +This document describes the telemetry that Firefox records for the Firefox +Suggest feature. That is, it describes Firefox Suggest telemetry recorded on the +client. It also discusses the data that Firefox sends to the Merino service. + +For information on other telemetry related to the address bar, see the general +address bar :doc:`telemetry` document. For information on all telemetry in +Firefox, see the toolkit :doc:`/toolkit/components/telemetry/index` document. + +.. contents:: + :depth: 2 + + +Histograms +---------- + +The following histograms are recorded for Firefox Suggest. For general +information on histogram telemetry in Firefox, see the +:doc:`/toolkit/components/telemetry/collection/histograms` document. + +FX_URLBAR_MERINO_LATENCY_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This histogram records the latency in milliseconds of the Merino source for +suggestions, or in other words, the time from Firefox's request to the Merino +server to the time Firefox receives a response. It is an exponential histogram +with 50 buckets and values between 0 and 30000 (0s and 30s). + +Changelog + Firefox 93.0 + Introduced. [Bug 1727799_] + +.. _1727799: https://bugzilla.mozilla.org/show_bug.cgi?id=1727799 + +FX_URLBAR_MERINO_LATENCY_WEATHER_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This histogram records the latency in milliseconds of weather suggestions from +Merino. It is updated in addition to ``FX_URLBAR_MERINO_LATENCY_MS`` and has the +same properties. It is an exponential histogram with 50 buckets and values +between 0 and 30000 (0s and 30s). + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +FX_URLBAR_MERINO_RESPONSE +~~~~~~~~~~~~~~~~~~~~~~~~~ + +This categorical histogram records a summary of each fetch from the Merino +server. It has the following categories: + +:0 "success": + The fetch completed without any error before the timeout elapsed and it + included at least one suggestion. (Before Firefox 110.0, this category meant + simply that the fetch completed without any error before the timeout elapsed + regardless of whether it included any suggestions.) +:1 "timeout": + The timeout elapsed before the fetch completed or otherwise failed. +:2 "network_error": + The fetch failed due to a network error before the timeout elapsed. e.g., the + user's network or the Merino server was down. +:3 "http_error": + The fetch completed before the timeout elapsed but the server returned an + error. +:4 "no_suggestion": + The fetch completed without any error before the timeout elapsed and it did + not include any suggestions. + +Changelog + Firefox 94.0.2 + Introduced. [Bug 1737923_] + + Firefox 110.0 + Added the ``no_suggestion`` category. The meaning of the ``success`` + category was changed from "The fetch completed without any error before the + timeout elapsed" to "The fetch completed without any error before the + timeout elapsed and it included at least one suggestion." [Bug 1804536_] + +.. _1737923: https://bugzilla.mozilla.org/show_bug.cgi?id=1737923 +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +FX_URLBAR_MERINO_RESPONSE_WEATHER +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This categorical histogram records a summary of each fetch for weather +suggestions from the Merino server. It is updated in addition to +``FX_URLBAR_MERINO_RESPONSE`` and has the same categories. + +:0 "success": + The fetch completed without any error before the timeout elapsed and it + included at least one suggestion. +:1 "timeout": + The timeout elapsed before the fetch completed or otherwise failed. +:2 "network_error": + The fetch failed due to a network error before the timeout elapsed. e.g., the + user's network or the Merino server was down. +:3 "http_error": + The fetch completed before the timeout elapsed but the server returned an + error. +:4 "no_suggestion": + The fetch completed without any error before the timeout elapsed and it did + not include any suggestions. + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This histogram records the latency in milliseconds of the remote settings source +for suggestions, or in other words, the time from when Firefox starts fetching a +suggestion from remote settings to the time the suggestion is retrieved. It is +an exponential histogram with 50 buckets and values between 0 and 30000 (0s and +30s). + +Note that unlike Merino, fetches from remote settings happen entirely on the +client, so remote settings latencies are expected to be much smaller than Merino +latencies. + +Changelog + Firefox 94.0.2 + Introduced. [Bug 1737651_] + +.. _1737651: https://bugzilla.mozilla.org/show_bug.cgi?id=1737651 + +Scalars +------- + +The following scalars are recorded for Firefox Suggest. For general information +on scalar telemetry in Firefox, see the +:doc:`/toolkit/components/telemetry/collection/scalars` document. + +browser.ui.interaction.preferences_panePrivacy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user clicks a Firefox Suggest +checkbox or toggle switch in the preferences UI. Keys are the following: + +:firefoxSuggestBestMatch: + This key is incremented when the "Top pick" checkbox is clicked. +:firefoxSuggestBestMatchLearnMore: + This key is incremented when opening the learn more link for best match. +:firefoxSuggestDataCollectionToggle: + This key is incremented when the toggle switch for data collection + is clicked. +:firefoxSuggestNonsponsoredToggle: + This key is incremented when the toggle switch for non-sponsored suggestions + is clicked. +:firefoxSuggestSponsoredToggle: + This key is incremented when the toggle switch for sponsored suggestions + is clicked. + +Changelog + Firefox 94.0.2 + Introduced firefoxSuggestDataCollectionToggle, + firefoxSuggestNonsponsoredToggle and firefoxSuggestSponsoredToggle. + [Bug 1735976_] + + Firefox 99.0 + Introduced firefoxSuggestBestMatch. [Bug 1755100_] + Introduced firefoxSuggestBestMatchLearnMore. [Bug 1756917_] + +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 +.. _1755100: https://bugzilla.mozilla.org/show_bug.cgi?id=1755100 +.. _1756917: https://bugzilla.mozilla.org/show_bug.cgi?id=1756917 + +contextual.services.quicksuggest.block_dynamic_wikipedia +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +dynamic wikipedia suggestion. Each key is the index at which a suggestion +appeared in the results (1-based), and the corresponding value is the number +of dismissals at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.block_nonsponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +non-sponsored suggestion, including both best matches and the usual +non-best-match suggestions. Each key is the index at which a suggestion appeared +in the results (1-based), and the corresponding value is the number of +dismissals at that index. + +Changelog + Firefox 101.0 + Introduced. [Bug 1761059_] + +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 + +contextual.services.quicksuggest.block_nonsponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +non-sponsored best match. Each key is the index at which a suggestion appeared +in the results (1-based), and the corresponding value is the number of +dismissals at that index. + +Changelog + Firefox 101.0 + Introduced. [Bug 1761059_] + +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 + +contextual.services.quicksuggest.block_sponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +sponsored suggestion, including both best matches and the usual non-best-match +suggestions. Each key is the index at which a suggestion appeared in the results +(1-based), and the corresponding value is the number of dismissals at that +index. + +Changelog + Firefox 101.0 + Introduced. [Bug 1761059_] + +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 + +contextual.services.quicksuggest.block_sponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +sponsored best match. Each key is the index at which a suggestion appeared in +the results (1-based), and the corresponding value is the number of dismissals +at that index. + +Changelog + Firefox 101.0 + Introduced. [Bug 1761059_] + +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 + +contextual.services.quicksuggest.block_weather +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user dismisses ("blocks") a +Firefox Suggest weather suggestion. Each key is the index at which a suggestion +appeared in the results (1-based), and the corresponding value is the number of +dismissals at that index. + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +contextual.services.quicksuggest.click +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a suggestion. Each key +is the index at which a suggestion appeared in the results (1-based), and the +corresponding value is the number of clicks at that index. + +Changelog + Firefox 87.0 + Introduced. [Bug 1693927_] + + Firefox 109.0 + Removed. [Bug 1800993_] + +.. _1693927: https://bugzilla.mozilla.org/show_bug.cgi?id=1693927 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.click_dynamic_wikipedia +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a dynamic +wikipedia suggestion. Each key is the index at which a suggestion appeared +in the results (1-based), and the corresponding value is the number of +clicks at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.click_nav_notmatched +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a heuristic result was clicked while a +navigational suggestion was absent. It is recorded only when the Nimbus variable +``recordNavigationalSuggestionTelemetry`` is true. (The variable is false by +default.) + +Each key is the type of heuristic result that was clicked. Key names are the +same as the heuristic result type names recorded in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.click_nav_shown_heuristic +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a heuristic result was clicked while a +navigational suggestion was present. It is recorded only when the Nimbus +variable ``recordNavigationalSuggestionTelemetry`` is true. (The variable is +false by default.) + +Each key is the type of heuristic result that was clicked. Key names are the +same as the heuristic result type names recorded in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.click_nav_shown_nav +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a navigational suggestion was clicked. +It is recorded only when the Nimbus variable +``recordNavigationalSuggestionTelemetry`` is true. (The variable is false by +default.) + +Each key is the type of heuristic result that was present at the time of the +engagement. Key names are the same as the heuristic result type names recorded +in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.click_nav_superceded +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a heuristic result was clicked when a +navigational suggestion was matched but superseded by the heuristic. It is +recorded only when the Nimbus variable ``recordNavigationalSuggestionTelemetry`` +is true. (The variable is false by default.) + +Each key is the type of heuristic result that was clicked. Key names are the +same as the heuristic result type names recorded in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.click_nonsponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a non-sponsored +suggestion. Each key is the index at which a suggestion appeared in the +results (1-based), and the corresponding value is the number of clicks at +that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.click_nonsponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a non-sponsored best +match. Each key is the index at which a suggestion appeared in the results +(1-based), and the corresponding value is the number of clicks at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.click_sponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a sponsored suggestion. +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of clicks at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.click_sponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a sponsored best +match. Each key is the index at which a suggestion appeared in the results +(1-based), and the corresponding value is the number of clicks at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.click_weather +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks a weather suggestion. +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of clicks at that index. + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +contextual.services.quicksuggest.exposure_weather +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records weather suggestion exposures. It is incremented each +time the user is shown a weather suggestion. It can be compared to the +``urlbar.zeroprefix.exposure`` scalar (see :doc:`telemetry`) to determine the +percentage of zero-prefix exposures that included weather suggestions. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of exposures at that index. + +Changelog + Firefox 110.0 + Introduced. [Bug 1806765_] + + Firefox 114.0 + Removed since the weather suggestion is no longer triggered on zero prefix. + [Bug 1831971_] + +.. _1806765: https://bugzilla.mozilla.org/show_bug.cgi?id=1806765 +.. _1831971: https://bugzilla.mozilla.org/show_bug.cgi?id=1831971 + +contextual.services.quicksuggest.help +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +suggestion. Each key is the index at which a suggestion appeared in the results +(1-based), and the corresponding value is the number of help button clicks at +that index. + +Changelog + Firefox 87.0 + Introduced. [Bug 1693927_] + + Firefox 109.0 + Removed. [Bug 1800993_] + +.. _1693927: https://bugzilla.mozilla.org/show_bug.cgi?id=1693927 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.help_dynamic_wikipedia +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +dynamic wikipedia suggestion. Each key is the index at which a suggestion +appeared in the results (1-based), and the corresponding value is the number +of help button clicks at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.help_nonsponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +non-sponsored suggestion. Each key is the index at which a suggestion appeared in the +results (1-based), and the corresponding value is the number of help button clicks +at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.help_nonsponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +non-sponsored best match. Each key is the index at which a suggestion appeared +in the results (1-based), and the corresponding value is the number of help +button clicks at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.help_sponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +sponsored suggestion. Each key is the index at which a suggestion appeared in the +results (1-based), and the corresponding value is the number of help button clicks +at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.help_sponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +sponsored best match. Each key is the index at which a suggestion appeared in +the results (1-based), and the corresponding value is the number of help button +clicks at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.help_weather +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar is incremented each time the user picks the help button in a +weather suggestion. Each key is the index at which a suggestion appeared in the +results (1-based), and the corresponding value is the number of help button +clicks at that index. + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +contextual.services.quicksuggest.impression +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records suggestion impressions. It is incremented each time +the user is shown a suggestion and the following two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a suggestion was present in the + results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 87.0 + Introduced. [Bug 1693927_] + + Firefox 109.0 + Removed. [Bug 1800993_] + +.. _1693927: https://bugzilla.mozilla.org/show_bug.cgi?id=1693927 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.impression_dynamic_wikipedia +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records dynamic wikipedia impressions. It is incremented +each time the user is shown a dynamic wikipedia suggestion and the following +two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a dynamic wikipedia suggestion + was present in the results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.impression_nav_notmatched +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a urlbar engagement occurred while a +navigational suggestion was absent. It is recorded only when the Nimbus variable +``recordNavigationalSuggestionTelemetry`` is true. (The variable is false by +default.) + +Each key is the type of heuristic result that was present at the time of the +engagement. Key names are the same as the heuristic result type names recorded +in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.impression_nav_shown +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a urlbar engagement occurred while a +navigational suggestion was present. It is recorded only when the Nimbus +variable ``recordNavigationalSuggestionTelemetry`` is true. (The variable is +false by default.) + +Each key is the type of heuristic result that was present at the time of the +engagement. Key names are the same as the heuristic result type names recorded +in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.impression_nav_superceded +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records how many times a urlbar engagement occurred when a +navigational suggestion was matched but superseded by a heuristic result. It is +recorded only when the Nimbus variable ``recordNavigationalSuggestionTelemetry`` +is true. (The variable is false by default.) + +Each key is the type of heuristic result that was present at the time of the +engagement. Key names are the same as the heuristic result type names recorded +in Glean telemetry. + +Changelog + Firefox 112.0 + Introduced. [Bug 1819797_] + +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 + +contextual.services.quicksuggest.impression_nonsponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records suggestion impressions. It is incremented each time +the user is shown a non-sponsored suggestion and the following two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a suggestion was present in the + results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.impression_nonsponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records non-sponsored best match impressions. It is +incremented each time the user is shown a non-sponsored best match and the +following two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a non-sponsored best match was + present in the results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.impression_sponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records suggestion impressions. It is incremented each time +the user is shown a sponsored suggestion and the following two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a suggestion was present in the + results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 109.0 + Introduced. [Bug 1800993_] + +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +contextual.services.quicksuggest.impression_sponsored_bestmatch +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records sponsored best match impressions. It is incremented +each time the user is shown a sponsored best match and the following two +conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a sponsored best match was + present in the results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 99.0 + Introduced. [Bug 1752953_] + +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 + +contextual.services.quicksuggest.impression_weather +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This keyed scalar records weather suggestion impressions. It is incremented each +time the user is shown a weather suggestion and the following two conditions +hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a weather suggestion was + present in the results. + +Each key is the index at which a suggestion appeared in the results (1-based), +and the corresponding value is the number of impressions at that index. + +Changelog + Firefox 110.0 + Introduced. [Bug 1804536_] + +.. _1804536: https://bugzilla.mozilla.org/show_bug.cgi?id=1804536 + +Events +------ + +The following Firefox Suggest events are recorded in the +``contextservices.quicksuggest`` category. For general information on event +telemetry in Firefox, see the +:doc:`/toolkit/components/telemetry/collection/events` document. + +contextservices.quicksuggest.data_collect_toggled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when the +``browser.urlbar.quicksuggest.dataCollection.enabled`` pref is toggled. The pref +can be toggled in the following ways: + +- The user can toggle it in the preferences UI. +- The user can toggle it in about:config. + +The event is also recorded when the user opts in to the online modal dialog, +with one exception: If the user has already enabled data collection using the +preferences UI or about:config, then the pref's user value is already +true. Opting in doesn't change the user value, so no event is recorded. + +The event's objects are the following: + +:enabled: + Recorded when the pref is flipped from false to true. +:disabled: + Recorded when the pref is flipped from true to false. + +Changelog + Firefox 94.0.2 + Introduced. [Bug 1735976_] + +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 + +contextservices.quicksuggest.enable_toggled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when the +``browser.urlbar.suggest.quicksuggest.nonsponsored`` pref is toggled. The pref +can be toggled in the following ways: + +- The user can toggle it in the preferences UI. +- The user can toggle it in about:config. + +The event's objects are the following: + +:enabled: + Recorded when the pref is flipped from false to true. +:disabled: + Recorded when the pref is flipped from true to false. + +Changelog + Firefox 87.0: + Introduced. The event corresponds to the + ``browser.urlbar.suggest.quicksuggest`` pref. [Bug 1693126_] + + Firefox 94.0.2: + ``browser.urlbar.suggest.quicksuggest`` is replaced with + ``browser.urlbar.suggest.quicksuggest.nonsponsored``, and this event now + corresponds to the latter pref. [Bug 1735976_] + + Firefox 96.0: + The event is no longer recorded when the user interacts with the online + modal dialog since the ``browser.urlbar.suggest.quicksuggest.nonsponsored`` + pref is no longer set when the user opts in or out. [Bug 1740965_] + +.. _1693126: https://bugzilla.mozilla.org/show_bug.cgi?id=1693126 +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 +.. _1740965: https://bugzilla.mozilla.org/show_bug.cgi?id=1740965 + +contextservices.quicksuggest.engagement +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when an engagement occurs in the address bar while a +Firefox Suggest suggestion is present. In other words, it is recorded in two +cases: + +- The user picks a Firefox Suggest suggestion or a related UI element like its + help button. +- While a Firefox Suggest suggestion is present in the address bar, the user + picks some other row. + +The event's objects are the following possible values: + +:block: + The user dismissed ("blocked") the suggestion. +:click: + The user picked the suggestion. +:help: + The user picked the suggestion's help button. +:impression_only: + The user picked some other row. +:other: + The user engaged with the suggestion in some other way, for example by picking + a command in the result menu. This is a catch-all category and going forward + Glean telemetry should be preferred. + +The event's ``extra`` contains the following properties: + +:match_type: + "best-match" if the suggestion was a best match or "firefox-suggest" if it was + a non-best-match suggestion. +:position: + The index of the suggestion in the list of results (1-based). +:suggestion_type: + The type of suggestion, one of: "sponsored", "nonsponsored", + "dynamic-wikipedia", "navigational" +:source: + The source of suggestion, one of: "remote-settings", "merino" + +Changelog + Firefox 101.0 + Introduced. [Bug 1761059_] + + Firefox 109.0 + ``source`` is added. [Bug 1800993_] + ``dynamic-wikipedia`` is added as a value of ``suggestion_type``. [Bug 1800993_] + + Firefox 112.0 + ``navigational`` is added as a value of ``suggestion_type``. [Bug 1819797_] + + Firefox 114.0 + ``other`` is added as a value of the event object. [Bug 1827943_] + +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 +.. _1819797: https://bugzilla.mozilla.org/show_bug.cgi?id=1819797 +.. _1827943: https://bugzilla.mozilla.org/show_bug.cgi?id=1827943 + +contextservices.quicksuggest.impression_cap +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when an event related to an impression cap occurs. The +event's objects are the following possible values: + +:hit: + Recorded when an impression cap is hit. +:reset: + Recorded when a cap's counter is reset because its interval period has + elapsed. The implementation may batch multiple consecutive reset events for a + cap in a single telemetry event; see the ``eventCount`` discussion below. + Reset events are reported only when a cap's interval period elapses while + Firefox is running. + +The event's ``extra`` contains the following properties: + +:count: + The number of impressions during the cap's interval period. +:eventCount: + The number of impression cap events reported in the telemetry event. This is + necessary because the implementation may batch multiple consecutive "reset" + events for a cap in a single telemetry event. When that occurs, this value + will be greater than 1, ``startDate`` will be the timestamp at which the + first event's interval period started, ``eventDate`` will be the timestamp at + which the last event's interval period ended, and ``count`` will be the number + of impressions during the first event's interval period. (The implementation + guarantees that reset events are batched only when the number of impressions + for all subsequent interval periods is zero.) For "hit" events, + ``eventCount`` will always be 1. +:eventDate: + The event's timestamp, in number of milliseconds since Unix epoch. For "reset" + events, this is the timestamp at which the cap's interval period ended. If + ``eventCount`` is greater than 1, it's the timestamp at which the last + interval period ended. For "hit" events, this is the timestamp at which the + cap was hit. +:impressionDate: + The timestamp of the most recent impression, in number of milliseconds since + Unix epoch. +:intervalSeconds: + The number of seconds in the cap's interval period. For lifetime caps, this + value will be "Infinity". +:maxCount: + The maximum number of impressions allowed in the cap's interval period. +:startDate: + The timestamp at which the cap's interval period started, in number of + milliseconds since Unix epoch. +:type: + The type of cap, one of: "sponsored", "nonsponsored" + +Changelog + Firefox 101.0 + Introduced. [Bug 1761058_, 1765881_] + +.. _1761058: https://bugzilla.mozilla.org/show_bug.cgi?id=1761058 +.. _1765881: https://bugzilla.mozilla.org/show_bug.cgi?id=1765881 + +contextservices.quicksuggest.opt_in_dialog +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when the user interacts with the online modal dialog. +The event's objects are the following: + +:accept: + The user accepted the dialog and opted in. This object was removed in Firefox + 96.0.2. +:accept_2: + The user accepted the dialog and opted in. +:close_1: + The user clicked close button or something similar link on the introduction + section. The user remains opted out in this case. +:dismiss_1: + The user dismissed the dialog by pressing the Escape key or some unknown way + on the introduction section. The user remains opted out in this case. +:dismiss_2: + The user dismissed the dialog by pressing the Escape key or some unknown way + on main section. The user remains opted out in this case. +:dismissed_escape_key: + The user dismissed the dialog by pressing the Escape key. The user remains + opted out in this case. This object was removed in Firefox 96.0.2. +:dismissed_other: + The dialog was dismissed in some unknown way. One case where this can happen + is when the dialog is replaced with another higher priority dialog like the + one shown when quitting the app. The user remains opted out in this case. + This object was removed in Firefox 96.0.2. +:learn_more: + The user clicked "Learn more". The user remains opted out in this case. This + object was removed in Firefox 96.0.2. +:learn_more_1: + The user clicked "Learn more" on the introduction section. The user remains + opted out in this case. +:learn_more_2: + The user clicked "Learn more" on the main section. The user remains opted out + in this case. +:not_now: + The dialog was dismissed in some way without opting in. This object was + removed in Firefox 94.0. +:not_now_2: + The user clicked "Not now" link on main section. The user remains opted out in + this case. +:not_now_link: + The user clicked "Not now". The user remains opted out in this case. This + object was removed in Firefox 96.0.2. +:reject_2: + The user rejected the dialog and opted out. +:settings: + The user clicked the "Customize" button. The user remains opted out in this + case. This object was removed in Firefox 96.0.2. + +Changelog + Firefox 92.0.1 + Introduced. Objects are: ``accept``, ``settings``, ``learn_more``, and + ``not_now``. ``not_now`` is recorded when the dialog is dismissed in any + manner not covered by the other objects. [Bug 1723860_] + + Firefox 94.0 + Objects changed to: ``accept``, ``dismissed_escape_key``, + ``dismissed_other``, ``learn_more``, ``not_now_link``, and ``settings``. + [Bug 1733687_] + + Firefox 96.0.2 + Objects changed to: ``accept_2``, ``reject_2``, ``learn_more_2``, + ``close_1``, ``not_now_2``, ``dismiss_1`` and ``dismiss_2``. + [Bug 1745026_] + + Firefox 100.0 + Objects changed to: ``accept_2``, ``reject_2``, ``learn_more_1``, + ``learn_more_2``, ``close_1``, ``not_now_2``, ``dismiss_1`` and + ``dismiss_2``. + [Bug 1761171_] + +.. _1723860: https://bugzilla.mozilla.org/show_bug.cgi?id=1723860 +.. _1733687: https://bugzilla.mozilla.org/show_bug.cgi?id=1733687 +.. _1745026: https://bugzilla.mozilla.org/show_bug.cgi?id=1745026 +.. _1761171: https://bugzilla.mozilla.org/show_bug.cgi?id=1761171 + +contextservices.quicksuggest.sponsored_toggled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This event is recorded when the +``browser.urlbar.suggest.quicksuggest.sponsored`` pref is toggled. The pref can +be toggled in the following ways: + +- The user can toggle it in the preferences UI. +- The user can toggle it in about:config. + +The event's objects are the following: + +:enabled: + Recorded when the pref is flipped from false to true. +:disabled: + Recorded when the pref is flipped from true to false. + +Changelog + Firefox 92.0.1 + Introduced. [Bug 1728430_] + + Firefox 96.0: + The event is no longer recorded when the user interacts with the online + modal dialog since the ``browser.urlbar.suggest.quicksuggest.sponsored`` + pref is no longer set when the user opts in or out. [Bug 1740965_] + +.. _1728430: https://bugzilla.mozilla.org/show_bug.cgi?id=1728430 +.. _1740965: https://bugzilla.mozilla.org/show_bug.cgi?id=1740965 + +Environment +----------- + +The following preferences are recorded in telemetry environment data. For +general information on telemetry environment data in Firefox, see the +:doc:`/toolkit/components/telemetry/data/environment` document. + +browser.urlbar.quicksuggest.onboardingDialogChoice +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This pref records the user's choice in the online modal dialog. If the dialog +was shown multiple times, it records the user's most recent choice. It is a +string-valued pref with the following possible values: + +:<empty string>: + The user has not made a choice (e.g., because the dialog hasn't been shown). +:accept: + The user accepted the dialog and opted in. This object was removed in Firefox + 96.0.2. +:accept_2: + The user accepted the dialog and opted in. +:close_1: + The user clicked close button or something similar link on the introduction + section. The user remains opted out in this case. +:dismiss_1: + The user dismissed the dialog by pressing the Escape key or some unknown way + on the introduction section. The user remains opted out in this case. +:dismiss_2: + The user dismissed the dialog by pressing the Escape key or some unknown way + on main section. The user remains opted out in this case. +:dismissed_escape_key: + The user dismissed the dialog by pressing the Escape key. The user remains + opted out in this case. This object was removed in Firefox 96.0.2. +:dismissed_other: + The dialog was dismissed in some unknown way. One case where this can happen + is when the dialog is replaced with another higher priority dialog like the + one shown when quitting the app. The user remains opted out in this case. This + object was removed in Firefox 96.0.2. +:learn_more: + The user clicked "Learn more". The user remains opted out in this case. This + object was removed in Firefox 96.0.2. +:learn_more_1: + The user clicked "Learn more" on the introduction section. The user remains + opted out in this case. +:learn_more_2: + The user clicked "Learn more" on the main section. The user remains opted out + in this case. +:not_now_2: + The user clicked "Not now" link on main section. The user remains opted out in + this case. +:not_now_link: + The user clicked "Not now". The user remains opted out in this case. This + object was removed in Firefox 96.0.2. +:reject_2: + The user rejected the dialog and opted out. +:settings: + The user clicked the "Customize" button. The user remains opted out in this + case. This object was removed in Firefox 96.0.2. + +Changelog + Firefox 94.0 + Introduced. [Bug 1734447_] + + Firefox 96.0.2 + Added ``accept_2``, ``reject_2``, ``learn_more_2``, ``close_1``, + ``not_now_2``, ``dismiss_1``, ``dismiss_2`` and removed ``accept``, + ``dismissed_escape_key``, ``dismissed_other``, ``learn_more``, + ``not_now_link``, ``settings``. [Bug 1745026_] + + Firefox 100.0 + Added ``learn_more_1``. [Bug 1761171_] + +.. _1734447: https://bugzilla.mozilla.org/show_bug.cgi?id=1734447 +.. _1745026: https://bugzilla.mozilla.org/show_bug.cgi?id=1745026 +.. _1761171: https://bugzilla.mozilla.org/show_bug.cgi?id=1761171 + +browser.urlbar.quicksuggest.dataCollection.enabled +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This boolean pref records whether the user has opted in to data collection for +Firefox Suggest. It is false by default. It is set to true when the user opts in +to the online modal dialog. The user can also toggle it in the preferences UI +and about:config. + +Changelog + Firefox 94.0.2 + Introduced. [Bug 1735976_] + +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 + +browser.urlbar.suggest.quicksuggest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This pref no longer exists and is not recorded. It was replaced with +``browser.urlbar.suggest.quicksuggest.nonsponsored`` in Firefox 94.0.2. Prior to +94.0.2, this boolean pref recorded whether suggestions in general were enabled. + +Changelog + Firefox 92.0.1 + Introduced. [Bug 1730721_] + + Firefox 94.0.2 + Replaced with ``browser.urlbar.suggest.quicksuggest.nonsponsored``. [Bug + 1735976_] + +.. _1730721: https://bugzilla.mozilla.org/show_bug.cgi?id=1730721 +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 + +browser.urlbar.suggest.quicksuggest.nonsponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This boolean pref records whether non-sponsored suggestions are enabled. In both +the offline and online scenarios it is true by default. The user can also toggle +it in the preferences UI and about:config. + +Changelog + Firefox 94.0.2 + Introduced. It replaces ``browser.urlbar.suggest.quicksuggest``. [Bug + 1735976_] + + Firefox 96.0: + The pref is now true by default in the online scenario. Previously it was + false by default in online. For users who were enrolled in the online + scenario in older versions and who did not opt in or otherwise enable + non-sponsored suggestions, the pref will remain false when upgrading. For + all other users, it will default to true when/if they are enrolled in + online. [Bug 1740965_] + +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 +.. _1740965: https://bugzilla.mozilla.org/show_bug.cgi?id=1740965 + +browser.urlbar.suggest.quicksuggest.sponsored +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This boolean pref records whether sponsored suggestions are enabled. In both the +offline and online scenarios it is true by default. The user can also toggle it +in the preferences UI and about:config. + +Changelog + Firefox 92.0.1 + Introduced. [Bug 1730721_] + + Firefox 96.0: + The pref is now true by default in the online scenario. Previously it was + false by default in online. For users who were enrolled in the online + scenario in older versions and who did not opt in or otherwise enable + sponsored suggestions, the pref will remain false when upgrading. For all + other users, it will default to true when/if they are enrolled in + online. [Bug 1740965_] + +.. _1730721: https://bugzilla.mozilla.org/show_bug.cgi?id=1730721 +.. _1740965: https://bugzilla.mozilla.org/show_bug.cgi?id=1740965 + +Contextual Services Pings +------------------------- + +The following custom telemetry pings are recorded for Firefox Suggest +suggestions. For general information on custom telemetry pings in Firefox, see +the `Custom Ping`_ document. + +.. _Custom Ping: https://docs.telemetry.mozilla.org/cookbooks/new_ping.html#sending-a-custom-ping + +Block +~~~~~ + +A block ping is recorded when the user dismisses ("blocks") a suggestion. Its +payload includes the following: + +:advertiser: + The name of the suggestion's advertiser. +:block_id: + A unique identifier for the suggestion (a.k.a. a keywords block). +:context_id: + A UUID representing this user. Note that it's not client_id, nor can it be + used to link to a client_id. +:iab_category: + The suggestion's category, either "22 - Shopping" or "5 - Education". +:improve_suggest_experience_checked: + A boolean indicating whether the user has opted in to improving the Firefox + Suggest experience. There are two ways for the user to opt in, either in an + opt-in modal experiment or by toggling a switch in Firefox's settings. +:match_type: + "best-match" if the suggestion was a best match or "firefox-suggest" if it was + a non-best-match suggestion. +:position: + The index of the suggestion in the list of results (1-based). +:request_id: + A request identifier for each API request to Merino. This is only included for + suggestions provided by Merino. +:source: + The source of the suggestion, either "remote-settings" or "merino". + +Changelog + Firefox 101.0 + Introduced. [Bug 1764669_] + + Firefox 103.0 + ``scenario`` is removed from the payload and + ``improve_suggest_experience_checked`` is added. [Bug 1776797_] + + Firefox 109.0 + ``source`` is added. [Bug 1800993_] + +.. _1764669: https://bugzilla.mozilla.org/show_bug.cgi?id=1764669 +.. _1776797: https://bugzilla.mozilla.org/show_bug.cgi?id=1776797 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +Click +~~~~~ + +A click ping is recorded when the user picks a suggestion. Its payload includes +the following: + +:advertiser: + The name of the suggestion's advertiser. +:block_id: + A unique identifier for the suggestion (a.k.a. a keywords block). +:context_id: + A UUID representing this user. Note that it's not client_id, nor can it be + used to link to a client_id. +:improve_suggest_experience_checked: + A boolean indicating whether the user has opted in to improving the Firefox + Suggest experience. There are two ways for the user to opt in, either in an + opt-in modal experiment or by toggling a switch in Firefox's settings. +:match_type: + "best-match" if the suggestion was a best match or "firefox-suggest" if it was + a non-best-match suggestion. +:position: + The index of the suggestion in the list of results (1-based). +:reporting_url: + The reporting URL of the suggestion, normally pointing to the ad partner's + reporting endpoint. +:request_id: + A request identifier for each API request to Merino. This is only included for + suggestions provided by Merino. +:source: + The source of the suggestion, either "remote-settings" or "merino". + +Changelog + Firefox 87.0 + Introduced. The payload is: ``advertiser``, ``block_id``, ``position``, and + ``reporting_url``. [Bug 1689365_] + + Firefox 92.0.1 + ``scenario`` is added to the payload. [Bug 1729576_] + + Firefox 94.0.2 + ``request_id`` is added to the payload. [Bug 1736117_] + + Firefox 99.0 + ``match_type`` is added to the payload. [Bug 1754622_] + + Firefox 103.0 + ``scenario`` is removed from the payload and + ``improve_suggest_experience_checked`` is added. [Bug 1776797_] + + Firefox 109.0 + ``source`` is added. [Bug 1800993_] + +.. _1689365: https://bugzilla.mozilla.org/show_bug.cgi?id=1689365 +.. _1729576: https://bugzilla.mozilla.org/show_bug.cgi?id=1729576 +.. _1736117: https://bugzilla.mozilla.org/show_bug.cgi?id=1736117 +.. _1754622: https://bugzilla.mozilla.org/show_bug.cgi?id=1754622 +.. _1776797: https://bugzilla.mozilla.org/show_bug.cgi?id=1776797 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +Impression +~~~~~~~~~~ + +An impression ping is recorded when the user is shown a suggestion and the +following two conditions hold: + +- The user has completed an engagement with the address bar by picking a result + in it or by pressing the Enter key. +- At the time the user completed the engagement, a suggestion was present in the + results. + +It is also recorded when the user dismisses ("blocks") a suggestion. + +The impression ping payload contains the following: + +:advertiser: + The name of the suggestion's advertiser. +:block_id: + A unique identifier for the suggestion (a.k.a. a keywords block). +:context_id: + A UUID representing this user. Note that it's not client_id, nor can it be + used to link to a client_id. +:improve_suggest_experience_checked: + A boolean indicating whether the user has opted in to improving the Firefox + Suggest experience. There are two ways for the user to opt in, either in an + opt-in modal experiment or by toggling a switch in Firefox's settings. +:is_clicked: + Whether or not the user also clicked the suggestion. When true, we will also + send a separate click ping. When the impression ping is recorded because the + user dismissed ("blocked") the suggestion, this will be false. +:match_type: + "best-match" if the suggestion was a best match or "firefox-suggest" if it was + a non-best-match suggestion. +:position: + The index of the suggestion in the list of results (1-based). +:reporting_url: + The reporting URL of the suggestion, normally pointing to the ad partner's + reporting endpoint. +:request_id: + A request identifier for each API request to Merino. This is only included for + suggestions provided by Merino. +:source: + The source of the suggestion, either "remote-settings" or "merino". + +Changelog + Firefox 87.0 + Introduced. The payload is: ``advertiser``, ``block_id``, ``is_clicked``, + ``matched_keywords``, ``position``, ``reporting_url``, and + ``search_query``. ``matched_keywords`` and ``search_query`` are always + included in the payload and are always identical: They both record the exact + search query as typed by the user. [Bug 1689365_] + + Firefox 91.0.1 (Release and ESR) + ``matched_keywords`` and ``search_query`` are always recorded as empty + strings. [Bug 1725492_] + + Firefox 92.0.1 + - When the user's scenaro is "online", ``matched_keywords`` records the full + keyword of the matching suggestion and ``search_query`` records the exact + search query as typed by the user; otherwise both are recorded as empty + strings. [Bug 1728188_, 1729576_] + - ``scenario`` is added to the payload. [Bug 1729576_] + + Firefox 94.0.2 + - When the user has opted in to data collection and the matching suggestion + is provided by remote settings, ``matched_keywords`` records the full + keyword of the suggestion and ``search_query`` records the exact search + query as typed by the user; otherwise both are excluded from the ping. + [Bug 1736117_, 1735976_] + - ``request_id`` is added to the payload. [Bug 1736117_] + + Firefox 97.0 + - Stop sending ``search_query`` and ``matched_keywords`` in the custom + impression ping for Firefox Suggest. [Bug 1748348_] + + Firefox 99.0 + ``match_type`` is added to the payload. [Bug 1754622_] + + Firefox 101.0 + The impression ping is now also recorded when the user dismisses ("blocks") + a suggestion. [Bug 1761059_] + + Firefox 103.0 + ``scenario`` is removed from the payload and + ``improve_suggest_experience_checked`` is added. [Bug 1776797_] + + Firefox 109.0 + ``source`` is added. [Bug 1800993_] + +.. _1689365: https://bugzilla.mozilla.org/show_bug.cgi?id=1689365 +.. _1725492: https://bugzilla.mozilla.org/show_bug.cgi?id=1725492 +.. _1728188: https://bugzilla.mozilla.org/show_bug.cgi?id=1728188 +.. _1729576: https://bugzilla.mozilla.org/show_bug.cgi?id=1729576 +.. _1736117: https://bugzilla.mozilla.org/show_bug.cgi?id=1736117 +.. _1735976: https://bugzilla.mozilla.org/show_bug.cgi?id=1735976 +.. _1748348: https://bugzilla.mozilla.org/show_bug.cgi?id=1748348 +.. _1754622: https://bugzilla.mozilla.org/show_bug.cgi?id=1754622 +.. _1761059: https://bugzilla.mozilla.org/show_bug.cgi?id=1761059 +.. _1776797: https://bugzilla.mozilla.org/show_bug.cgi?id=1776797 +.. _1800993: https://bugzilla.mozilla.org/show_bug.cgi?id=1800993 + +Nimbus Exposure Event +--------------------- + +A `Nimbus exposure event`_ is recorded once per app session when the user first +encounters the UI of an experiment in which they're enrolled. The timing of the +event depends on the experiment and branch. + +There are two Nimbus variables that determine the timing of the event: +``experimentType`` and the deprecated ``isBestMatchExperiment``. To determine +when the exposure event is recorded for a specific experiment and branch, +examine the experiment's recipe and look for one of these variables. + +Listed below are the supported values of ``experimentType`` and +``isBestMatchExperiment`` along with details on when their corresponding +exposure events are recorded. + +:experimentType = "best-match": + If the user is in a treatment branch and they did not disable best match, the + event is recorded the first time they trigger a best match; if the user is in + a treatment branch and they did disable best match, the event is not recorded + at all. If the user is in the control branch, the event is recorded the first + time they would have triggered a best match. (Users in the control branch + cannot "disable" best match since the feature is totally hidden from them.) +:experimentType = "modal": + If the user is in a treatment branch, the event is recorded when they are + shown an opt-in modal. If the user is in the control branch, the event is + recorded every time they would have been shown a modal, which is on every + startup where another non-Suggest modal does not appear. +:isBestMatchExperiment = true: + This is a deprecated version of ``experimentType == "best-match"``. +:All other experiments: + For all other experiments not listed above, the event is recorded the first + time the user triggers a Firefox Suggest suggestion. + +Changelog + Firefox 92.0 + Introduced. The event is always recorded the first time the user triggers + a Firefox Suggest suggestion regardless of the experiment they are enrolled + in. [Bug 1724076_, 1727392_] + + Firefox 99.0 + The ``isBestMatchExperiment = true`` case is added. [Bug 1752953_] + + Firefox 100.0 + The ``experimentType = "modal"`` case is added. + ``isBestMatchExperiment = true`` is deprecated in favor of + ``experimentType = "best-match"``. [Bug 1760596_] + +.. _Nimbus exposure event: https://experimenter.info/jetstream/jetstream/#enrollment-vs-exposure + +.. _1724076: https://bugzilla.mozilla.org/show_bug.cgi?id=1724076 +.. _1727392: https://bugzilla.mozilla.org/show_bug.cgi?id=1727392 +.. _1752953: https://bugzilla.mozilla.org/show_bug.cgi?id=1752953 +.. _1760596: https://bugzilla.mozilla.org/show_bug.cgi?id=1760596 + +Merino Search Queries +--------------------- + +Merino is a Mozilla service that provides Firefox Suggest suggestions. Along +with remote settings on the client, it is one of two possible sources for +Firefox Suggest. When Merino integration is enabled on the client and the user +has opted in to Firefox Suggest data collection, Firefox sends everything the +user types in the address bar to the Merino server. In response, Merino finds +relevant search results from its search providers and sends them to Firefox, +where they are shown to the user in the address bar. + +The user opts in to Firefox Suggest data collection when they either opt in to +the online modal dialog or they enable Firefox Suggest data collection in the +preferences UI. + +Merino queries are not telemetry per se but we include them in this document +since they necessarily involve data collection. + +Merino API +~~~~~~~~~~ + +Data that Firefox sends to the Merino server is summarized below. When Merino +integration is enabled on the client and the user has opted in to Firefox +Suggest data collection, this data is sent with every user keystroke in the +address bar. + +For details on the Merino API, see the `Merino documentation`_. + +.. _Merino documentation: https://mozilla-services.github.io/merino/api.html#suggest + +Search Query + The user's search query typed in the address bar. + + API parameter name: ``q`` + +Session ID + A UUID that identifies the user's current search session in the address bar. + This ID is unique per search session. A search session ends when the focus + leaves the address bar or a timeout of 5 minutes elapses, whichever comes + first. + + API parameter name: ``sid`` + +Sequence Number + A zero-based integer that is incremented after a response is received from + Merino. It is reset at the end of each search session along with the session + ID. + + API parameter name: ``seq`` + +Client Variants + Optional. A list of experiments or rollouts that are affecting the Firefox + Suggest user experience. If Merino recognizes any of them, it will modify its + behavior accordingly. + + API parameter name: ``client_variants`` + +Providers + Optional. A list of providers to use for this request. If specified, only + suggestions from the listed providers will be returned. Otherwise Merino will + use a default set of providers. + + API parameter name: ``providers`` diff --git a/browser/components/urlbar/docs/index.rst b/browser/components/urlbar/docs/index.rst new file mode 100644 index 0000000000..8abc9c974d --- /dev/null +++ b/browser/components/urlbar/docs/index.rst @@ -0,0 +1,56 @@ +Address Bar +=========== + +This document describes the implementation of Firefox's address bar, also known +as the quantumbar or urlbar. The address bar was also called the awesomebar +until Firefox 68, when it was substantially rewritten. + +The address bar is a specialized search access point that aggregates data from +several different sources, including: + + * Places (Firefox's history and bookmarks system) + * Search engines (including search suggestions) + * WebExtensions + * Open tabs + +Where to Start +-------------- + +If you want a high level, nontechnical summary of how the address bar works, +read :doc:`nontechnical-overview`. + +If you are interested in the technical details, you might want to skip ahead to +:doc:`overview`. + +Codebase +-------- + +The address bar code lives in `browser/components/urlbar <https://searchfox.org/mozilla-central/source/browser/components/urlbar/>`_. + +Table of Contents +----------------- + +.. toctree:: + + nontechnical-overview + overview + lifetime + utilities + telemetry + firefox-suggest-telemetry + debugging + ranking + experiments + dynamic-result-types + preferences + testing + contact + +API Reference +------------- + +.. toctree:: + + UrlbarController + UrlbarInput + UrlbarView diff --git a/browser/components/urlbar/docs/lifetime.rst b/browser/components/urlbar/docs/lifetime.rst new file mode 100644 index 0000000000..f12aba6e60 --- /dev/null +++ b/browser/components/urlbar/docs/lifetime.rst @@ -0,0 +1,109 @@ +Search Lifecycle +================ + +When a character is typed into the address bar, or the address bar is focused, +we initiate a search. What follows is a simplified version of the +lifetime of a search, describing the pipeline that returns results for a typed +string. Some parts of the query lifetime are intentionally omitted from this +document for clarity. + +The search described in this document is internal to the address bar. It is not +the search sent to the default search engine when you press Enter. Parts of this +process often occur multiple times per keystroke, as described below. + +It is recommended that you first read the :doc:`nontechnical-overview` to become +familiar with the terminology in this document. This document is current as +of April 2023. + +#. + The user types a query (e.g. "coffee near me") into the *UrlbarInput* + `<input> DOM element <https://searchfox.org/mozilla-central/rev/1f4f99a8f331cce8467a50742178b6d46914ab89/browser/base/content/navigator-toolbox.inc.xhtml#330-336>`_. + That DOM element `tells <https://searchfox.org/mozilla-central/rev/1f4f99a8f331cce8467a50742178b6d46914ab89/browser/components/urlbar/UrlbarInput.sys.mjs#3312>`_ + *UrlbarInput* that text is being input. + +#. + *UrlbarInput* `starts a search <https://searchfox.org/mozilla-central/rev/1f4f99a8f331cce8467a50742178b6d46914ab89/browser/components/urlbar/UrlbarInput.sys.mjs#3395>`_. + It `creates <https://searchfox.org/mozilla-central/rev/1f4f99a8f331cce8467a50742178b6d46914ab89/browser/components/urlbar/UrlbarInput.sys.mjs#1549>`_ + a `UrlbarQueryContext <https://firefox-source-docs.mozilla.org/browser/urlbar/overview.html#the-urlbarquerycontext>`_ + and `passes it to UrlbarController <https://searchfox.org/mozilla-central/rev/1f4f99a8f331cce8467a50742178b6d46914ab89/browser/components/urlbar/UrlbarInput.sys.mjs#1548>`_. + The query context is an object that will exist for the lifetime of the query + and it's how we keep track of what results to show. It contains information + like what kind of results are allowed, the search string ("coffee near me", + in this case), and other information about the state of the Urlbar. A new + *UrlbarQueryContext* is created every time the text in the input changes. + +#. + *UrlbarController* `tells UrlbarProvidersManager <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarController.sys.mjs#140>`_ + that the providers should fetch results. + +#. + *UrlbarProvidersManager* tells `each <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#408>`_ + provider to decide if it wants to provide results for this query by calling + their `isActive <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#422>`_ + methods. The provider can decide whether or not it will be active for this + query. Some providers are rarely active: for example, + *UrlbarProviderTopSites* `isn't active if the user has typed a search string <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProviderTopSites.sys.mjs#97>`_. + +#. + *UrlbarProvidersManager* then tells the *active* providers to fetch results by + `calling their startQuery method <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#462>`_. + +#. + The providers fetch results for the query asynchronously. Each provider + fetches results in a different way. As one example, if the default search + engine is Google, *UrlbarProviderSearchSuggestions* would send the string + "coffee near me" to Google. Google would return a list of suggestions and + *UrlbarProviderSearchSuggestions* would create a *UrlbarResult* for each one. + +#. + The providers send their results back to *UrlbarProvidersManager*. They do + this one result at a time by `calling the addCallback callback <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProviderSearchSuggestions.sys.mjs#292>`_ + passed into startQuery. *UrlbarProvidersManager* takes all the results from all the + providers and `puts them into the list of unsorted results <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#607>`_. + + Due to the asynchronous and parallel nature of providers, this and the + following steps may occur multiple times per search. Some providers may take + longer than others to return their results. We don't want to wait for slow + providers before showing results. To handle slow providers, + *UrlbarProvidersManager* gathers results from providers in "chunks". A timer + fires on an internal. Every time the timer fires, we take whatever results we + have from the active providers (the "chunk" of results) and perform the + following steps. + +#. + *UrlbarProvidersManager* `asks <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#648>`_ + *UrlbarMuxer* to sort the unsorted results. + +#. + *UrlbarMuxer* chooses the results that will be shown to the user. It groups + and sorts the results to determine the order in which the results will be + shown. This process usually involves discarding irrelevant and duplicate + results. We also cap results at a limit, defined in the + ``browser.urlbar.maxRichResults`` preference. + +#. + Once the results are sorted, *UrlbarProvidersManager* + `tells UrlbarController <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarProvidersManager.sys.mjs#675>`_ + that results are ready to be shown. + +#. + *UrlbarController* `sends out a notification <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarController.sys.mjs#213>`_ + that results are ready to be shown. *UrlbarView* was `listening <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarView.sys.mjs#662>`_ + for that notification. Once the view gets the notification, it `calls #updateResults <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarView.sys.mjs#670>`_ + to create `DOM nodes <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarView.sys.mjs#1185>`_ + for each *UrlbarResult* and `inserts them <https://searchfox.org/mozilla-central/rev/0ffaecaa075887ab07bf4c607c61ea2faa81b172/browser/components/urlbar/UrlbarView.sys.mjs#1156>`_ + into the view's DOM element. + + As described above, we may reach this step multiple times per search. That + means we may be updating the view multiple times per keystroke. A view that + visibly changes many times after a single keystroke is perceived as + "flickering" by the user. As a result, we try to limit the number of times + the view needs to update. + + + .. figure:: assets/lifetime/lifetime.png + :alt: A chart with boxes representing the various components of the + address bar. An arrow moves between the boxes to illustrate a query + moving through the components. + :scale: 80% + :align: center diff --git a/browser/components/urlbar/docs/nontechnical-overview.rst b/browser/components/urlbar/docs/nontechnical-overview.rst new file mode 100644 index 0000000000..e3fb6d7600 --- /dev/null +++ b/browser/components/urlbar/docs/nontechnical-overview.rst @@ -0,0 +1,628 @@ +Nontechnical Overview +===================== + +This document provides a high level, nontechnical overview of Firefox's address +bar, with a focus on the different types of results it shows and how it chooses +them. + +.. contents:: + :depth: 2 + + +Terminology +----------- + +This document uses a small number of terms of art that would be helpful to +understand up front. + +Input + The text box component of the address bar. In contrast, we use "address bar" + to refer to the whole system comprising the input, the view, and the logic + that determines the results that are shown in the view based on the text in + the input. + +Result + An individual item that is shown in the view. There are many different types + of results, including bookmarks, history, open tabs, and search suggestions. + +View + The panel that opens below the input when the input is focused. It contains + the results. + +Maximum Result Count +-------------------- + +The view shows a maximum of 10 results by default. This number is controlled by +a hidden preference, ``browser.urlbar.maxRichResults``. + +Search Strings +-------------- + +If the user has not modified the text in the input or the text in the input is +empty, we say that the user's **search string** is empty, or in other words, +there is no search string. In contrast, when the user has modified the text in +the input and the text is non-empty, then the search string is that non-empty +text. + +.. figure:: assets/nontechnical-overview/empty-url.png + :alt: Image of the address bar input showing a URL + :scale: 50% + :align: center + + Empty search string: The input text has not been modified + +.. figure:: assets/nontechnical-overview/empty-placeholder.png + :alt: Image of the address bar input showing its placeholder text + :scale: 50% + :align: center + + Empty search string: The input text is empty (and the input is showing its + placeholder text) + +.. figure:: assets/nontechnical-overview/non-empty.png + :alt: Image of the address bar input showing "porcupines" text + :scale: 50% + :align: center + + Non-empty search string: The input text has been modified and is non-empty + +The distinction between empty and non-empty search strings is helpful to +understand for the following sections. + +Top Sites +--------- + +When the search string is empty and the user focuses the input, the view opens +and shows the user's top sites. They are the same top sites that appear on the +new-tab page except their number is capped to the maximum number of address bar +results (10). If the user has fewer top sites than the maximum number of results +(as is the case in a new profile), then only that number of results is shown. + +.. figure:: assets/nontechnical-overview/top-sites.png + :alt: Image of the address bar view showing top sites + :scale: 50% + :align: center + + Top sites on a new en-US profile + +This behavior can be turned off by going to about:preferences#privacy and +unchecking “Shortcuts” in the “Address Bar” section. In that case, the view +closes when the search string is empty. + +Searches +-------- + +When the search string is non-empty, the address bar performs a search and +displays the matching results in the view. Multiple separate searches of +different sources are actually performed, and the results from each source are +combined, sorted, and capped to the maximum result count to display the final +list of results. In address bar terminology, each source is called a +**provider**. + +Each provider produces one or more types of results based on the search +string. The most common result types include the following (not exhaustive): + +.. figure:: assets/nontechnical-overview/search-suggestion.png + :alt: Image of a search suggestion result with text "porcupine meatballs" + :scale: 50% + :align: center + + Search suggestions from the user's default engine (magnifying glass icon) + +.. figure:: assets/nontechnical-overview/form-history.png + :alt: Image of a previous search result with text "porcupines" + :scale: 50% + :align: center + + Previous searches the user has performed from the address bar and search bar + (clock icon) + +.. figure:: assets/nontechnical-overview/bookmark.png + :alt: Image of a bookmark result with text "Porcupine - Wikipedia" + :scale: 50% + :align: center + + Bookmarks + +.. figure:: assets/nontechnical-overview/history.png + :alt: Image of a history result with text "Porcupines | National Geographic" + :scale: 50% + :align: center + + History + +.. figure:: assets/nontechnical-overview/open-tab.png + :alt: Image of an open tab result with text "Porcupines | San Diego Zoo + Animals & Plants" + :scale: 50% + :align: center + + Open tabs (switch to tab) + +.. figure:: assets/nontechnical-overview/remote-tab.png + :alt: Image of a remote tab result with text "Porcupine | rodent | + Britannica" + :scale: 50% + :align: center + + Remote tabs (via Sync) + +How the address bar combines and sorts results from different providers is +discussed below in `Result Composition`_. + +The Heuristic Result +-------------------- + +The first result in the view is special and is called the **heuristic** +result. As the user types each character in their search string, the heuristic +result is updated and automatically selected, and its purpose is to show the +user what will happen when they press the enter key without first selecting a +(non-heuristic) result. The heuristic result is so called because it shows +Firefox's best guess for what the user is trying to do based on their search +string. + +The heuristic result is determined by running through a number of different +heuristics and picking the one that first matches the search string. The most +important heuristics in the order that Firefox runs through them are: + +*Is the search string...* + +1. An omnibox extension keyword? Extensions using the omnibox API can register + keywords by which they become activated. +2. A bookmark keyword? The user can associate a keyword with each bookmark. + Typing a bookmark keyword plus an optional search string and pressing enter + will visit the bookmark. + + .. figure:: assets/nontechnical-overview/bookmark-keyword.png + :alt: Image of the address bar input with text "bug 1677126" and a + bookmark keyword heuristic result + :scale: 50% + :align: center + + Typing "bug" triggers a Bugzilla bookmark with the keyword "bug" + +3. A domain name or URL that should be autofilled? **Autofill** is the name of + the feature where the input completes the domain names and URLs of bookmarks + and frequently visited sites as the user is typing them. (Firefox autofills + “to the next slash”, meaning it first autofills domain names and then partial + paths.) + + .. figure:: assets/nontechnical-overview/autofill.png + :alt: Image of the address bar input with text "mozilla.org/" with + "illa.org/" selected and an autofill heuristic result + :scale: 50% + :align: center + + After typing "moz", the rest of mozilla.org is automatically completed + +4. A valid URL? If so, visit the URL. (This includes fixing common typos like + “mozilla..org” and “mozilla.ogr”. Valid URLs are based on the `Public Suffix + List`_. The user can also specify an allow-list using hidden preferences to + support domains like localhost.) + + .. figure:: assets/nontechnical-overview/visit.png + :alt: Image of the address bar input with text "porcupine-fancy.org" and a + visit heuristic result + :scale: 50% + :align: center + + Typing a URL that isn't bookmarked or in history + + .. _Public Suffix List: https://publicsuffix.org/ + +5. Ultimately fall back to performing a search using the default engine. (The + user can opt out of this fallback by setting the hidden preference + ``keyword.enabled`` to false. In that case, Firefox stops at the previous + step and attempts to visit the user's search string as if it were a URL.) + + .. figure:: assets/nontechnical-overview/search-heuristic.png + :alt: Image of the address bar input with text "porcupines" and a search + heuristic result + :scale: 50% + :align: center + + Typing a string that will perform a search using the default engine + +Result Composition +------------------ + +For a given search string, the address bar performs multiple separate searches +of different providers and then combines their results to display the final +list. The way in which results are combined and sorted is called **result +composition**. Result composition is based on the concept of result groups, one +group after another, with different types of results in each group. + +The default result composition is described next, starting with the first +result. + +1. Heuristic Result +~~~~~~~~~~~~~~~~~~~ + +The first result is always the heuristic result. + +2. Extension Omnibox Results +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The next group of results is those provided by extensions using the omnibox +API. Most users never encounter these results because they are provided only by +extensions that use this feature, and even then the user must type certain +extension-defined keywords to trigger them. There are at most 6 results in this +group. + +3. Search Suggestions +~~~~~~~~~~~~~~~~~~~~~ + +The next group is search suggestions. Typically this group contains 6 results, +but the exact number depends on certain factors described later in `Result +Composition Nuances`_. There are actually three types of search suggestions: + +* Previous searches the user has performed from the address bar and search bar + (denoted with a clock icon): + + .. image:: assets/nontechnical-overview/form-history.png + :alt: Image of a previous search result with text "porcupines" + :scale: 50% + :align: center + + This is the only type of search suggestion that is generated by Firefox alone, + without the help of a search engine. When the user performs a search using an + engine from the address bar or search bar (and only the address bar and search + bar), Firefox stores the search string, and then when the user starts to type + it again, Firefox includes it as a result to make it easy to perform past + searches. (Firefox does not store search strings used within web pages like + google.com.) + +* Suggestions from the user's default engine (denoted with a magnifying glass + icon): + + .. image:: assets/nontechnical-overview/search-suggestion.png + :alt: Image of a search suggestion result with text "porcupine meatballs" + :scale: 50% + :align: center + + These are fetched from the engine if the engine provides the necessary access + point. The ordering and total number of these suggestions is determined by the + engine. + +* Google-specific "tail" suggestions, which look like "... foo" and are provided + for long and/or specific queries to help the user narrow their search: + + .. image:: assets/nontechnical-overview/tail-suggestions.png + :alt: Image of a tail suggestion results with text "porcupine abc def" in + the input and two suggestions with text "... definition " and + "... defense" + :scale: 50% + :align: center + + These are fetched from Google when Google is the user's default engine. The + ordering and total number of these suggestions is determined by Google. + +The search suggestions group typically contains two previous searches followed +by four engine suggestions, but the exact numbers depend on the number of +matching previous searches and engine suggestions. Previous searches are limited +in number so that they don’t dominate this group, allowing remote suggestions to +provide content discovery benefits. Tail suggestions are shown only when there +are no other suggestions. + +The user can opt out of showing search suggestions in the address bar by +visiting about:preferences#search and unchecking "Provide search suggestions" or +"Show search suggestions in address bar results". + +4. General Results +~~~~~~~~~~~~~~~~~~ + +The final group of results is a general group that includes the following types: + +* Bookmarks +* History +* Open tabs (switch to tab) +* Remote tabs (via Sync) +* Sponsored and Firefox Suggest results (part of the Firefox Suggest feature) + +This general group is labeled "Firefox Suggest" in the Firefox Suggest feature. + +Typically this group contains 3 results, but as with search suggestions, the +exact number depends on certain factors (see `Result Composition Nuances`_). + +Most results within this group are first matched against the search string on +their titles and URLs and then sorted by a metric called **frecency**, a +combination of how frequently and how recently a page is visited. The top three +results are shown regardless of their specific types. + +This is the only group that is sorted by frecency. + +A few important complexities of this group are discussed in the next +subsections. The final subsection describes frecency in more detail. + +Adaptive History +................ + +The first few bookmark and history results in the general group may come from +**adaptive history**, a system that associates specific user search strings with +URLs. (It's also known as **input history**.) When the user types a search +string and picks a result, Firefox stores a database record that associates the +string with the result's URL. When the user types the string or a part of it +again, Firefox will try to show the URL they picked last time. This allows +Firefox to adapt to a user's habit of visiting certain pages via specific search +strings. + +This mechanism is mostly independent of frecency. URLs in the adaptive history +database have their own sorting score based on how many times they have been +used in the past. The score decays daily so that infrequently used search +strings and URLs aren't retained forever. (If two adaptive history results have +the same score, they are secondarily sorted by frecency.) + +Within the general group, the number of adaptive history results is not limited, +but typically there aren't many of them for a given search string. + +Open and Remote Tabs +.................... + +Unlike bookmarks and history, open and remote tabs don't have a "natural" +frecency, meaning a frecency that's updated in response to user actions as +described below in Frecency_. Tabs that match the search string are assigned +constant frecencies so they can participate in the sorting within the general +group. Open tabs are assigned a frecency of 1000, and remote tabs are assigned a +frecency of 1001. Picking appropriate frecencies is a bit of an art, but Firefox +has used these values for some time. + +Sponsored and Firefox Suggest Results +..................................... + +Sponsored and Firefox Suggest results are an exception within this group. They +are matched on predetermined keywords, and when present, they always appear last +in the general group. Frecency isn't involved at all. + +Frecency +........ + +Frecency is a complex topic on its own, but in summary, each URL stored in +Firefox's internal history database has a numeric score, the frecency, +associated with it. Larger numbers mean higher frecencies, and URLs with higher +frecencies are more likely to be surfaced to the user via the address bar. Each +time the user visits a URL, Firefox increases its frecency by a certain "boost" +amount that depends on how the visit is performed -- whether the user picked it +in the address bar, clicked its link on a page, clicked it in the history +sidebar, etc. In order to prevent frecencies from growing unbounded and to +penalize URLs that haven't been visited in a while, Firefox decays the +frecencies of all URLs over time. + +For details on frecency, see `The Frecency Algorithm`_. + +.. _The Frecency Algorithm: https://docs.google.com/document/d/10LRRXVGWWWcjEZIZ2YlEmuKkQqh2RaTclStFHNnPqQ8/edit#heading=h.588hanspexub + +Preferences that Affect Result Composition +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There are a number of options in about:preferences that affect result +composition. + +The user can opt out of showing search suggestions in the address bar by +unchecking "Provide search suggestions" or "Show search suggestions in address +bar results" in about:preferences#search. (The first checkbox applies to both +the address bar and search bar, so it acts as a global toggle.) + +.. figure:: assets/nontechnical-overview/prefs-show-suggestions.png + :alt: Image of the preferences UI that allows the user to opt out of search + suggestions + :scale: 50% + :align: center + + Preferences allowing the user to opt out of search suggestions + +By default, the search suggestions group is shown before the general results +group, but unchecking "Show search suggestions ahead of browsing history in +address bar results" in about:preferences#search does the opposite. In that +case, typically the general results group will contain at most 6 results and the +search suggestions group will contain at most 3. In other words, regardless of +which group comes first, typically the first will contain 6 results and the +second will contain 3. + +.. figure:: assets/nontechnical-overview/prefs-suggestions-first.png + :alt: Image of the preferences UI that allows the user to choose whether + search suggestions are shown before general results + :scale: 50% + :align: center + + Preference allowing the user to choose which group is shown first + +The “Address Bar” section in about:preferences#privacy has several checkboxes +that allow for finer control over the types of results that appear in the view. +The top sites feature can be turned off by unchecking “Shortcuts” in this +section. + +.. figure:: assets/nontechnical-overview/prefs-privacy.png + :alt: Image of the preferences UI that allows the user to choose which + results are shown + :scale: 50% + :align: center + + Preferences allowing the user to choose which results are shown + +Result Composition Nuances +-------------------------- + +Among the search suggestions and general results groups, the group that's shown +first typically contains 6 results and the other group contains 3 results. The +exact number in each group depends on several factors: + +* The total maximum result count (controlled by the + ``browser.urlbar.maxRichResults`` hidden preference). + + The total number of results in the two groups scales up and down to + accommodate this number so that the view is always full of results. + +* The number of extension results. + + The extension results group comes before both groups, so if there are any + extension results, there are fewer available slots for search suggestions and + general results. + +* The number of matching results. + + The search string may match only one or two search suggestions or general + results, for example. + +* The number of results in the other group. + + The first group will try to contain 6 results and the second will try to + contain 3, but if either one is unable to fill up, then the other group will + be allowed to grow to make up the difference. + +Other Result Types +------------------ + +The most common result types are discussed above. This section walks through the +other types. + +An important trait these types have in common is that they do not belong to any +group. Most of them appear at specific positions within the view. + +Search Interventions +~~~~~~~~~~~~~~~~~~~~ + +Search interventions help the user perform a task based on their search string. +There are three kinds of interventions, and each is triggered by typing a +certain set of phrases_ in the input. They always appear as the second result, +after the heuristic result. + +The three kinds of interventions are: + +.. figure:: assets/nontechnical-overview/intervention-clear.png + :alt: Image of the clear intervention result with text "Clear your cache, + cookies, history and more" + :scale: 50% + :align: center + + Clear history, cache, and other data search intervention + +.. figure:: assets/nontechnical-overview/intervention-refresh.png + :alt: Image of the refresh intervention result with text "Restore default + settings and remove old add-ons for optimal performance" + :scale: 50% + :align: center + + Refresh Firefox search intervention + +.. figure:: assets/nontechnical-overview/intervention-update.png + :alt: Image of the update intervention result with text "The latest Firefox + is downloaded and ready to install" + :scale: 50% + :align: center + + Update Firefox search intervention + +Currently this feature is limited to English-speaking locales, but work is +ongoing to build a more sophisticated intent-matching platform to support other +locales, more complex search strings, and more kinds of interventions. + +.. _phrases: https://searchfox.org/mozilla-central/rev/c4d682be93f090e99d5f4049ceb7b6b6c03d0632/browser/components/urlbar/UrlbarProviderInterventions.jsm#64 + +Search Tips +~~~~~~~~~~~ + +Search tips inform the user they can perform searches directly from the +address bar. There are two kinds of search tips: + +.. figure:: assets/nontechnical-overview/search-tip-onboard.png + :alt: Image of the onboarding search tip with text "Type less, find more: + Search Google right from your address bar" + :scale: 50% + :align: center + + Onboarding search tip: Appears on the new-tab page + +.. figure:: assets/nontechnical-overview/search-tip-redirect.png + :alt: Image of the redirect search tip with text "Start your search in the + address bar to see suggestions from Google and your browsing history" + :scale: 50% + :align: center + + Redirect search tip: Appears on the home page of the user's default engine + (only for Google, Bing, and DuckDuckGo) + +In each case, the view automatically opens and shows the tip even if the user is +not interacting with the address bar. Each tip is shown at most four times, and +the user can stop them from appearing altogether by interacting with the address +bar or clicking the "Okay, Got It" button. + +Tab to Search +~~~~~~~~~~~~~ + +Tab to search allows the user to press the tab key to enter `search mode`_ while +typing the domain name of a search engine. There are two kinds of tab-to-search +results, and they always appear as the second result: + +.. figure:: assets/nontechnical-overview/tab-to-search-onboard.png + :alt: Image of the tab-to-search result with text "Search with Google" + :scale: 50% + :align: center + + Onboarding tab to search + +.. figure:: assets/nontechnical-overview/tab-to-search-regular.png + :alt: Image of the tab-to-search result with text "Search with Google" + :scale: 50% + :align: center + + Regular tab to search + +The onboarding type is shown until the user has interacted with it three times +over a period of at least 15 minutes, and after that the regular type is shown. + +Search Engine Offers +~~~~~~~~~~~~~~~~~~~~ + +Typing a single “@” shows a list of search engines. Selecting an engine enters +`search mode`_. + +.. figure:: assets/nontechnical-overview/search-offers.png + :alt: Image of the view showing search offer results + :scale: 50% + :align: center + + Search engine offers after typing “@” + +.. figure:: assets/nontechnical-overview/search-offers-selected.png + :alt: Image of the view showing search offer results with one selected + :scale: 50% + :align: center + + After pressing the down arrow key to select Google + +Search Mode +----------- + +**Search mode** is a feature that transforms the address bar into a search-only +access point for a particular engine. During search mode, search suggestions are +the only results shown in the view, and for that reason its result composition +differs from the usual composition. + +.. figure:: assets/nontechnical-overview/search-mode.png + :alt: Image of the view showing search mode + :scale: 50% + :align: center + + Search mode with Google as the selected engine + +Firefox shows suggestions in search mode even when the user has otherwise opted +out of them. Our rationale is that by entering search mode, the user has taken +an action that overrides their usual opt out. This allows the user to opt out +generally but opt back in at specific times. + +Search mode is an effective replacement for the legacy search bar and may +provide a good path forward for deprecating it. + +The user can enter search mode in many ways: + +* Picking a search shortcut button at the bottom of the view +* Typing an engine's keyword (which can be set in about:preferences#search, and + built-in engines have default keywords) +* Typing a single "?" followed by a space (to enter search mode with the default + engine) +* Typing a single "@" to list all engines and then picking one +* If the search bar is not also shown, pressing Ctrl+K (to enter search mode + with the default engine) + +To exit search mode, the user can backspace over the engine chiclet or click its +close button. diff --git a/browser/components/urlbar/docs/overview.rst b/browser/components/urlbar/docs/overview.rst new file mode 100644 index 0000000000..0ff1ee5ad7 --- /dev/null +++ b/browser/components/urlbar/docs/overview.rst @@ -0,0 +1,413 @@ +Architecture Overview +===================== + +The address bar is implemented as a *model-view-controller* (MVC) system. One of +the scopes of this architecture is to allow easy replacement of its components, +for easier experimentation. + +Each search is represented by a unique object, the *UrlbarQueryContext*. This +object, created by the *View*, describes the search and is passed through all of +the components, along the way it gets augmented with additional information. +The *UrlbarQueryContext* is passed to the *Controller*, and finally to the +*Model*. The model appends results to a property of *UrlbarQueryContext* in +chunks, it sorts them through a *Muxer* and then notifies the *Controller*. + +See the specific components below, for additional details about each one's tasks +and responsibilities. + + +The UrlbarQueryContext +---------------------- + +The *UrlbarQueryContext* object describes a single instance of a search. +It is augmented as it progresses through the system, with various information: + +.. highlight:: JavaScript +.. code:: + + UrlbarQueryContext { + allowAutofill; // {boolean} If true, providers are allowed to return + // autofill results. Even if true, it's up to providers + // whether to include autofill results, but when false, no + // provider should include them. + isPrivate; // {boolean} Whether the search started in a private context. + maxResults; // {integer} The maximum number of results requested. It is + // possible to request more results than the shown ones, and + // do additional filtering at the View level. + searchString; // {string} The user typed string. + userContextId; // {integer} The user context ID (containers feature). + + // Optional properties. + muxer; // {string} Name of a registered muxer. Muxers can be registered + // through the UrlbarProvidersManager. + providers; // {array} List of registered provider names. Providers can be + // registered through the UrlbarProvidersManager. + sources: {array} list of accepted UrlbarUtils.RESULT_SOURCE for the context. + // This allows to switch between different search modes. If not + // provided, a default will be generated by the Model, depending on + // the search string. + engineName: // {string} if sources is restricting to just SEARCH, this + // property can be used to pick a specific search engine, by + // setting it to the name under which the engine is registered + // with the search service. + currentPage: // {string} url of the page that was loaded when the search + // began. + prohibitRemoteResults: + // {boolean} This provides a short-circuit override for + // context.allowRemoteResults(). If it's false, then allowRemoteResults() + // will do its usual checks to determine whether remote results are + // allowed. If it's true, then allowRemoteResults() will immediately + // return false. Defaults to false. + + // Properties added by the Model. + results; // {array} list of UrlbarResult objects. + tokens; // {array} tokens extracted from the searchString, each token is an + // object in the form {type, value, lowerCaseValue}. + } + + +The Model +--------- + +The *Model* is the component responsible for retrieving search results based on +the user's input, and sorting them accordingly to their importance. +At the core is the `UrlbarProvidersManager <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarProvidersManager.jsm>`_, +a component tracking all the available search providers, and managing searches +across them. + +The *UrlbarProvidersManager* is a singleton, it registers internal providers on +startup and can register/unregister providers on the fly. +It can manage multiple concurrent queries, and tracks them internally as +separate *Query* objects. + +The *Controller* starts and stops queries through the *UrlbarProvidersManager*. +It's possible to wait for the promise returned by *startQuery* to know when no +more results will be returned, it is not mandatory though. +Queries can be canceled. + +.. note:: + + Canceling a query will issue an interrupt() on the database connection, + terminating any running and future SQL query, unless a query is running inside + a *runInCriticalSection* task. + +The *searchString* gets tokenized by the `UrlbarTokenizer <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarTokenizer.jsm>`_ +component into tokens, some of these tokens have a special meaning and can be +used by the user to restrict the search to specific result type (See the +*UrlbarTokenizer::TYPE* enum). + +.. caution:: + + The tokenizer uses heuristics to determine each token's type, as such the + consumer may want to check the value before applying filters. + +.. highlight:: JavaScript +.. code:: + + UrlbarProvidersManager { + registerProvider(providerObj); + unregisterProvider(providerObj); + registerMuxer(muxerObj); + unregisterMuxer(muxerObjOrName); + async startQuery(queryContext); + cancelQuery(queryContext); + // Can be used by providers to run uninterruptible queries. + runInCriticalSection(taskFn); + } + +UrlbarProvider +~~~~~~~~~~~~~~ + +A provider is specialized into searching and returning results from different +information sources. Internal providers are usually implemented in separate +*jsm* modules with a *UrlbarProvider* name prefix. External providers can be +registered as *Objects* through the *UrlbarProvidersManager*. +Each provider is independent and must satisfy a base API, while internal +implementation details may vary deeply among different providers. + +.. important:: + + Providers are singleton, and must track concurrent searches internally, for + example mapping them by UrlbarQueryContext. + +.. note:: + + Internal providers can access the Places database through the + *PlacesUtils.promiseLargeCacheDBConnection* utility. + +.. highlight:: JavaScript +.. code:: + + class UrlbarProvider { + /** + * Unique name for the provider, used by the context to filter on providers. + * Not using a unique name will cause the newest registration to win. + * @abstract + */ + get name() { + return "UrlbarProviderBase"; + } + /** + * The type of the provider, must be one of UrlbarUtils.PROVIDER_TYPE. + * @abstract + */ + get type() { + throw new Error("Trying to access the base class, must be overridden"); + } + /** + * Whether this provider should be invoked for the given context. + * If this method returns false, the providers manager won't start a query + * with this provider, to save on resources. + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {boolean} Whether this provider should be invoked for the search. + * @abstract + */ + isActive(queryContext) { + throw new Error("Trying to access the base class, must be overridden"); + } + /** + * Gets the provider's priority. Priorities are numeric values starting at + * zero and increasing in value. Smaller values are lower priorities, and + * larger values are higher priorities. For a given query, `startQuery` is + * called on only the active and highest-priority providers. + * @param {UrlbarQueryContext} queryContext The query context object + * @returns {number} The provider's priority for the given query. + * @abstract + */ + getPriority(queryContext) { + // By default, all providers share the lowest priority. + return 0; + } + /** + * Starts querying. + * @param {UrlbarQueryContext} queryContext The query context object + * @param {function} addCallback Callback invoked by the provider to add a new + * result. A UrlbarResult should be passed to it. + * @note Extended classes should return a Promise resolved when the provider + * is done searching AND returning results. + * @abstract + */ + startQuery(queryContext, addCallback) { + throw new Error("Trying to access the base class, must be overridden"); + } + /** + * Cancels a running query, + * @param {UrlbarQueryContext} queryContext The query context object to cancel + * query for. + * @abstract + */ + cancelQuery(queryContext) { + throw new Error("Trying to access the base class, must be overridden"); + } + } + +UrlbarMuxer +~~~~~~~~~~~ + +The *Muxer* is responsible for sorting results based on their importance and +additional rules that depend on the UrlbarQueryContext. The muxer to use is +indicated by the UrlbarQueryContext.muxer property. + +.. caution:: + + The Muxer is a replaceable component, as such what is described here is a + reference for the default View, but may not be valid for other implementations. + +.. highlight:: JavaScript +.. code:: + + class UrlbarMuxer { + /** + * Unique name for the muxer, used by the context to sort results. + * Not using a unique name will cause the newest registration to win. + * @abstract + */ + get name() { + return "UrlbarMuxerBase"; + } + /** + * Sorts UrlbarQueryContext results in-place. + * @param {UrlbarQueryContext} queryContext the context to sort results for. + * @abstract + */ + sort(queryContext) { + throw new Error("Trying to access the base class, must be overridden"); + } + } + + +The Controller +-------------- + +`UrlbarController <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarController.jsm>`_ +is the component responsible for reacting to user's input, by communicating +proper course of action to the Model (e.g. starting/stopping a query) and the +View (e.g. showing/hiding a panel). It is also responsible for reporting Telemetry. + +.. note:: + + Each *View* has a different *Controller* instance. + +.. highlight:: JavaScript +.. code:: + + UrlbarController { + async startQuery(queryContext); + cancelQuery(queryContext); + // Invoked by the ProvidersManager when results are available. + receiveResults(queryContext); + // Used by the View to listen for results. + addQueryListener(listener); + removeQueryListener(listener); + } + + +The View +-------- + +The View is the component responsible for presenting search results to the +user and handling their input. + +.. caution + + The View is a replaceable component, as such what is described here is a + reference for the default View, but may not be valid for other implementations. + +`UrlbarInput.jsm <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarInput.jsm>`_ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Implements an input box *View*, owns an *UrlbarView*. + +.. highlight:: JavaScript +.. code:: + + UrlbarInput { + constructor(options = { textbox, panel }); + // Uses UrlbarValueFormatter to highlight the base host, search aliases + // and to keep the host visible on overflow. + formatValue(val); + openResults(); + // Converts an internal URI (e.g. a URI with a username or password) into + // one which we can expose to the user. + makeURIReadable(uri); + // Handles an event which would cause a url or text to be opened. + handleCommand(); + // Called by the view when a result is selected. + resultsSelected(); + // The underlying textbox + textbox; + // The results panel. + panel; + // The containing window. + window; + // The containing document. + document; + // An UrlbarController instance. + controller; + // An UrlbarView instance. + view; + // Whether the current value was typed by the user. + valueIsTyped; + // Whether the context is in Private Browsing mode. + isPrivate; + // Whether the input box is focused. + focused; + // The go button element. + goButton; + // The current value, can also be set. + value; + } + +`UrlbarView.jsm <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarView.jsm>`_ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Represents the base *View* implementation, communicates with the *Controller*. + +.. highlight:: JavaScript +.. code:: + + UrlbarView { + // Manage View visibility. + open(); + close(); + // Invoked when the query starts. + onQueryStarted(queryContext); + // Invoked when new results are available. + onQueryResults(queryContext); + // Invoked when the query has been canceled. + onQueryCancelled(queryContext); + // Invoked when the query is done. This is invoked in any case, even if the + // query was canceled earlier. + onQueryFinished(queryContext); + // Invoked when the view opens. + onViewOpen(); + // Invoked when the view closes. + onViewClose(); + } + + +UrlbarResult +------------ + +An `UrlbarResult <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarResult.jsm>`_ +instance represents a single search result with a result type, that +identifies specific kind of results. +Each kind has its own properties, that the *View* may support, and a few common +properties, supported by all of the results. + +.. note:: + + Result types are also enumerated by *UrlbarUtils.RESULT_TYPE*. + +.. code-block:: + + UrlbarResult { + constructor(resultType, payload); + + type: {integer} One of UrlbarUtils.RESULT_TYPE. + source: {integer} One of UrlbarUtils.RESULT_SOURCE. + title: {string} A title that may be used as a label for this result. + icon: {string} Url of an icon for this result. + payload: {object} Object containing properties for the specific RESULT_TYPE. + autofill: {object} An object describing the text that should be + autofilled in the input when the result is selected, if any. + autofill.value: {string} The autofill value. + autofill.selectionStart: {integer} The first index in the autofill + selection. + autofill.selectionEnd: {integer} The last index in the autofill selection. + suggestedIndex: {integer} Suggest a preferred position for this result + within the result set. Undefined if none. + isSuggestedIndexRelativeToGroup: {boolean} Whether the suggestedIndex + property is relative to the result's group + instead of the entire result set. + } + +The following RESULT_TYPEs are supported: + +.. highlight:: JavaScript +.. code:: + + // An open tab. + // Payload: { icon, url, userContextId } + TAB_SWITCH: 1, + // A search suggestion or engine. + // Payload: { icon, suggestion, keyword, query, providesSearchMode, inPrivateWindow, isPrivateEngine } + SEARCH: 2, + // A common url/title tuple, may be a bookmark with tags. + // Payload: { icon, url, title, tags } + URL: 3, + // A bookmark keyword. + // Payload: { icon, url, keyword, postData } + KEYWORD: 4, + // A WebExtension Omnibox result. + // Payload: { icon, keyword, title, content } + OMNIBOX: 5, + // A tab from another synced device. + // Payload: { icon, url, device, title } + REMOTE_TAB: 6, + // An actionable message to help the user with their query. + // Payload: { buttons, helpL10n, helpUrl, icon, titleL10n, type } + TIP: 7, + // A type of result created at runtime, for example by an extension. + // Payload: { dynamicType } + DYNAMIC: 8, diff --git a/browser/components/urlbar/docs/preferences.rst b/browser/components/urlbar/docs/preferences.rst new file mode 100644 index 0000000000..d26299b9b1 --- /dev/null +++ b/browser/components/urlbar/docs/preferences.rst @@ -0,0 +1,264 @@ +Preferences +=========== + +This document describes Preferences affecting the Firefox's address bar. +Preferences that are generated and updated by code won't be described here. + +User Exposed +------------ +These preferences are exposed through the Firefox UI. + +browser.urlbar.shortcuts.bookmarks (boolean, default: true) + Whether to show the bookmark search shortcut button in the view. + Can be controlled from Search Preferences. + +browser.urlbar.shortcuts.tabs (boolean, default: true) + Whether to show the tabs search shortcut button in the view. + Can be controlled from Search Preferences. + +browser.urlbar.shortcuts.history (boolean, default: true) + Whether to show the history search shortcut button in the view. + Can be controlled from Search Preferences. + +browser.urlbar.showSearchSuggestionsFirst (boolean, default: true) + Whether to show search suggestions before general results. + Can be controlled from Search Preferences. + +browser.urlbar.showSearchTerms.enabled (boolean, default: true) + Whether to show the search term in the urlbar + on a default search engine results page. + Can be controlled from Search Preferences. + +browser.urlbar.suggest.bookmark (boolean, default: true) + Whether results will include the user's bookmarks. + Can be controlled from Privacy Preferences. + +browser.urlbar.suggest.history (boolean, default: true) + Whether results will include the user's history. + Can be controlled from Privacy Preferences. + +browser.urlbar.suggest.openpage (boolean, default: true) + Whether results will include switch-to-tab results. + Can be controlled from Privacy Preferences. + +browser.urlbar.suggest.searches (boolean, default: true) + Whether results will include search suggestions. + Can be controlled from Search Preferences. + +browser.urlbar.suggest.engines (boolean, default: true) + Whether results will include search engines (e.g. tab-to-search). + Can be controlled from Privacy Preferences. + +browser.urlbar.suggest.topsites (boolean, default: true) + Whether results will include top sites and the view will open on focus. + Can be controlled from Privacy Preferences. + +browser.search.suggest.enabled (boolean, default: true) + Whether search suggestions are enabled globally, including the separate search + bar. + Can be controlled from Search Preferences. + +browser.search.suggest.enabled.private (boolean, default: false) + When search suggestions are enabled, controls whether they are provided in + Private Browsing windows. + Can be controlled from Search Preferences. + + +Hidden +------ +These preferences are normally hidden, and should not be used unless you really +know what you are doing. + +browser.urlbar.accessibility.tabToSearch.announceResults (boolean: default: true) + Whether we announce to screen readers when tab-to-search results are inserted. + +browser.urlbar.autoFill (boolean, default: true) + Autofill is the the feature that automatically completes domains and URLs that + the user has visited as the user is typing them in the urlbar textbox. + +browser.urlbar.autoFill.adaptiveHistory.enabled (boolean, default: false) + Whether adaptive history autofill feature is enabled. + +browser.urlbar.autoFill.adaptiveHistory.useCountThreshold (float, default: 1.0) + Threshold for use count of input history that we handle as adaptive history + autofill. If the use count is this value or more, it will be a candidate. + +browser.urlbar.bestMatch.enabled (boolean, default: false) + Whether the best match feature is enabled. + +browser.urlbar.autoFill.stddevMultiplier (float, default: 0.0) + Affects the frecency threshold of the autofill algorithm. The threshold is + the mean of all origin frecencies, plus one standard deviation multiplied by + this value. + +browser.urlbar.ctrlCanonizesURLs (boolean, default: true) + Whether using `ctrl` when hitting return/enter in the URL bar (or clicking + 'go') should prefix 'www.' and suffix browser.fixup.alternate.suffix to the + user value prior to navigating. + +browser.urlbar.decodeURLsOnCopy (boolean, default: false) + Whether copying the entire URL from the location bar will put a human + readable (percent-decoded) URL on the clipboard. + +browser.urlbar.delay (number, default: 50) + The amount of time (ms) to wait after the user has stopped typing before + fetching certain results. Reducing this doesn't make the Address Bar faster, + it will instead make it access the disk more heavily, and potentially make it + slower. Certain results, like the heuristic, always skip this timer anyway. + +browser.urlbar.dnsResolveSingleWordsAfterSearch (number, default: 0) + Controls when to DNS resolve single word search strings, after they were + searched for. If the string is resolved as a valid host, show a + "Did you mean to go to 'host'" prompt. + Set to 0. 0: Never resolve, 1: Use heuristics, 2. Always resolve. + +browser.urlbar.eventTelemetry.enabled (boolean, default: false) + Whether telemetry events should be recorded. This is expensive and should only + be enabled by experiments with a small population. + +browser.urlbar.extension.timeout (integer, default: 400) + When sending events to extensions, they have this amount of time in + milliseconds to respond before timing out. This affects the omnibox API. + +browser.urlbar.filter.javascript (boolean, default: true) + When true, `javascript:` URLs are not included in search results for safety + reasons. + +browser.urlbar.formatting.enabled (boolean, default: true) + Applies URL highlighting and other styling to the text in the urlbar input + field. This should usually be enabled for security reasons. + +browser.urlbar.maxCharsForSearchSuggestions (integer, default: 100) + As a user privacy measure, we don't fetch results from remote services for + searches that start by pasting a string longer than this. The pref name + indicates search suggestions, but this is used for all remote results. + +browser.urlbar.maxHistoricalSearchSuggestions (integer, default: 2) + The maximum number of form history results to include as search history. + +browser.urlbar.maxRichResults (integer, default: 10) + The maximum number of results in the urlbar popup. + +browser.urlbar.merino.clientVariants (string, default: "") + Comma separated list of client variants to send to send to Merino. See + `Merino API docs <https://mozilla-services.github.io/merino/api.html#suggest>`_ + for more details. This is intended to be used by experiments, not directly set + by users. + +browser.urlbar.merino.enabled (boolean, default: false) + Whether Merino is enabled as a quick suggest source. + +browser.urlbar.merino.providers (string, default: "") + Comma-separated list of providers to request from the Merino server. Merino + will return suggestions only for these providers. See `Merino API docs`_ for + more details. + +browser.urlbar.openintab (boolean, default: false) + Whether address bar results should be opened in new tabs by default. + +browser.urlbar.quicksuggest.enabled (boolean, default: false) + Whether the quick suggest feature is enabled, i.e., sponsored and recommended + results related to the user's search string. This pref can be overridden by + the ``quickSuggestEnabled`` Nimbus variable. If false, neither sponsored nor + non-sponsored quick suggest results will be shown. If true, then we look at + the individual prefs ``browser.urlbar.suggest.quicksuggest.nonsponsored`` and + ``browser.urlbar.suggest.quicksuggest.sponsored``. + +browser.urlbar.quicksuggest.remoteSettings.enabled (boolean, default: true) + Whether remote settings is enabled as a quick suggest source. + +browser.urlbar.quicksuggest.dataCollection.enabled (boolean, default: false) + Whether data collection is enabled for quick suggest results. + +browser.urlbar.quicksuggest.shouldShowOnboardingDialog (boolean, default: true) + Whether to show the quick suggest onboarding dialog. + +browser.urlbar.richSuggestions.tail (boolean, default: true) + If true, we show tail search suggestions when available. + +browser.urlbar.searchTips.test.ignoreShowLimits (boolean, default: false) + Disables checks that prevent search tips being shown, thus showing them every + time the newtab page or the default search engine homepage is opened. + This is useful for testing purposes. + +browser.urlbar.speculativeConnect.enabled (boolean, default: true) + Speculative connections allow to resolve domains pre-emptively when the user + is likely to pick a result from the Address Bar. This allows for faster + navigation. + +browser.urlbar.sponsoredTopSites (boolean, default: false) + Whether top sites may include sponsored ones. + +browser.urlbar.suggest.bestmatch (boolean, default: true) + Whether to show the best match result is enabled. This pref is ignored if + browser.urlbar.bestMatch.enabled is false. + +browser.urlbar.suggest.quicksuggest.nonsponsored (boolean, default: false) + Whether results will include non-sponsored quick suggest suggestions. + +browser.urlbar.suggest.quicksuggest.sponsored (boolean, default: false) + Whether results will include sponsored quick suggest suggestions. + +browser.urlbar.switchTabs.adoptIntoActiveWindow (boolean, default: false) + When using switch to tabs, if set to true this will move the tab into the + active window, instead of just switching to it. + +browser.urlbar.trimURLs (boolean, default: true) + Clean-up URLs when showing them in the Address Bar. + +keyword.enabled (boolean, default: true) + By default, when the search string is not recognized as a potential url, + search for it with the default search engine. If set to false any string will + be handled as a potential URL, even if it's invalid. + +browser.fixup.dns_first_for_single_words (boolean, default: false) + If true, any single word search string will be sent to the DNS server before + deciding whether to search or visit it. This may add a delay to the urlbar. + + +Experimental +------------ +These preferences are experimental and not officially supported. They could be +removed at any time. + +browser.urlbar.suggest.calculator (boolean, default: false) + Whether results will include a calculator. + +browser.urlbar.unitConversion.enabled (boolean, default: false) + Whether unit conversion is enabled. + +browser.urlbar.unitConversion.suggestedIndex (integer, default: 1) + The index where we show unit conversion results. + +browser.urlbar.experimental.expandTextOnFocus (boolean, default: false) + Whether we expand the font size when the urlbar is focused. + +browser.urlbar.experimental.searchButton (boolean, default: false) + Whether to displays a permanent search button before the urlbar. + +browser.urlbar.keepPanelOpenDuringImeComposition (boolean, default: false) + Whether the results panel should be kept open during IME composition. The + panel may overlap with the IME compositor panel. + +browser.urlbar.restyleSearches (boolean, default: false) + When true, URLs in the user's history that look like search result pages + are restyled to look like search engine results instead of history results. + +browser.urlbar.update2.emptySearchBehavior (integer, default: 0) + Controls the empty search behavior in Search Mode: 0. Show nothing, 1. Show + search history, 2. Show search and browsing history + +Deprecated +---------- +These preferences should not be used and may be removed at any time. + +browser.urlbar.autoFill.searchEngines (boolean, default: false) + If true, the domains of the user's installed search engines will be + autofilled even if the user hasn't actually visited them. + +browser.urlbar.usepreloadedtopurls.enabled (boolean, default: false) + Results will include a built-in set of popular domains when this is true. + +browser.urlbar.usepreloadedtopurls.expire_days (integer, default: 14) + After this many days from the profile creation date, the built-in set of + popular domains will no longer be included in the results. diff --git a/browser/components/urlbar/docs/ranking.rst b/browser/components/urlbar/docs/ranking.rst new file mode 100644 index 0000000000..a1c9d03c3c --- /dev/null +++ b/browser/components/urlbar/docs/ranking.rst @@ -0,0 +1,229 @@ +======= +Ranking +======= + +Before results appear in the UrlbarView, they are fetched from providers. + +Each `UrlbarProvider <https://firefox-source-docs.mozilla.org/browser/urlbar/overview.html#urlbarprovider>`_ +implements its own internal ranking and returns sorted results. + +Externally all the results are ranked by the `UrlbarMuxer <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarMuxerUnifiedComplete.jsm>`_ +according to an hardcoded list of groups and sub-grups. + +.. NOTE:: Preferences can influence the groups order, for example by putting + Firefox Suggest before Search Suggestions. + +The Places provider, responsible to return history and bookmark results, uses +an internal ranking algorithm called Frecency. + +Frecency implementation +======================= + +Frecency is a term derived from `frequency` and `recency`, its scope is to provide a +ranking algorithm that gives importance both to how often a page is accessed and +when it was last visited. +Additionally, it accounts for the type of each visit through a bonus system. + +To account for `recency`, a bucketing system is implemented. +If a page has been visited later than the bucket cutoff, it gets the weight +associated with that bucket: + +- Up to 4 days old - weight 100 - ``places.frecency.firstBucketCutoff/Weight`` +- Up to 14 days old - weight 70 - ``places.frecency.secondBucketCutoff/Weight`` +- Up to 31 days old - weight 50 - ``places.frecency.thirdBucketCutoff/Weight`` +- Up to 90 days old - weight 30 - ``places.frecency.fourthBucketCutoff/Weight`` +- Anything else - weight 10 - ``places.frecency.defaultBucketWeight`` + +To account for `frequency`, the total number of visits to a page is used to +calculate the final score. + +The type of each visit is taken into account using specific bonuses: + +Default bonus + Any unknown type gets a default bonus. This is expected to be unused. + Pref ``places.frecency.defaultVisitBonus`` current value: 0. +Embed + Used for embedded/framed visits not due to user actions. These visits today + are stored in memory and never participate to frecency calculation. + Thus this is currently unused. + Pref ``places.frecency.embedVisitBonus`` current value: 0. +Framed Link + Used for cross-frame visits due to user action. + Pref ``places.frecency.framedLinkVisitBonus`` current value: 0. +Download + Used for download visits. It’s important to support link coloring for these + visits, but they are not necessarily useful address bar results (the Downloads + view can do a better job with these), so their frecency can be low. + Pref ``places.frecency.downloadVisitBonus`` current value: 0. +Reload + Used for reload visits (refresh same page). Low because it should not be + possible to influence frecency by multiple reloads. + Pref ``places.frecency.reloadVisitBonus`` current value: 0. +Redirect Source + Used when the page redirects to another one. + It’s a low value because we give more importance to the final destination, + that is what the user actually visits, especially for permanent redirects. + Pref ``places.frecency.redirectSourceVisitBonus`` current value: 25. +Temporary Redirect + Used for visits resulting from a temporary redirect (HTTP 307). + Pref ``places.frecency.tempRedirectVisitBonus`` current value: 40. +Permanent Redirect + Used for visits resulting from a permanent redirect (HTTP 301). This is the + new supposed destination for a url, thus the bonus is higher than temporary. + In this case it may be advisable to just pick the bonus for the source visit. + Pref ``places.frecency.permRedirectVisitBonus`` current value: 50. +Bookmark + Used for visits generated from bookmark views. + Pref ``places.frecency.bookmarkVisitBonus`` current value: 75. +Link + Used for normal visits, for example when clicking on a link. + Pref ``places.frecency.linkVisitBonus`` current value: 100. +Typed + Intended to be used for pages typed by the user, in reality it is used when + the user picks a url from the UI (history views or the Address Bar). + Pref ``places.frecency.typedVisitBonus`` current value: 2000. + +The above bonuses are applied to visits, in addition to that there are also a +few bonuses applied in case a page is not visited at all, both of these bonuses +can be applied at the same time: + +Unvisited bookmarked page + Used for pages that are bookmarked but unvisited. + Pref ``places.frecency.unvisitedBookmarkBonus`` current value: 140. +Unvisited typed page + Used for pages that were typed and now are bookmarked (otherwise they would + be orphans). + Pref ``places.frecency.unvisitedTypedBonus`` current value: 200. + +Two special frecency values are also defined: + +- ``-1`` represents a just inserted entry in the database, whose score has not + been calculated yet. +- ``0`` represents an entry for which a new value should not be calculated, + because it has a poor user value (e.g. place: queries) among search results. + +Finally, because calculating a score from all of the visits every time a new +visit is added would be expensive, only a sample of the last 10 +(pref ``places.frecency.numVisits``) visits is used. + +How frecency for a page is calculated +------------------------------------- + +.. mermaid:: + :align: center + :caption: Frecency calculation flow + + flowchart TD + start[URL] + a0{Has visits?} + a1[Get last 10 visit] + a2[bonus = unvisited_bonus + bookmarked + typed] + a3{bonus > 0?} + end0[Frecency = 0] + end1["frecency = age_bucket_weight * (bonus / 100)"] + a4[Sum points of all sampled visits] + a5{points > 0?} + end2[frecency = -1] + end3["Frecency = visit_count * (points / sample_size)"] + subgraph sub [Per each visit] + sub0[bonus = visit_type_bonus] + sub1{bookmarked?} + sub2[add bookmark bonus] + sub3["score = age_bucket_weight * (bonus / 100)"] + sub0 --> sub1 + sub1 -- yes --> sub2 + sub1 -- no --> sub3 + sub2 --> sub3; + end + start --> a0 + a0 -- no --> a2 + a2 --> a3 + a3 -- no --> end0 + a3 -- yes --> end1 + a0 -- yes --> a1 + a1 --> sub + sub --> a4 + a4 --> a5 + a5 -- no --> end2 + a5 -- yes --> end3 + +1. If the page is visited, get a sample of ``NUM_VISITS`` most recent visits. +2. For each visit get a transition bonus, depending on the visit type. +3. If the page is bookmarked, add to the bonus an additional bookmark bonus. +4. If the bonus is positive, get a bucket weight depending on the visit date. +5. Calculate points for the visit as ``age_bucket_weight * (bonus / 100)``. +6. Sum points for all the sampled visits. +7. If the points sum is zero, return a ``-1`` frecency, it will still appear in the UI. + Otherwise, frecency is ``visitCount * points / NUM_VISITS``. +8. If the page is unvisited and not bookmarked, or it’s a bookmarked place-query, + return a ``0`` frecency, to hide it from the UI. +9. If it’s bookmarked, add the bookmark bonus. +10. If it’s also a typed page, add the typed bonus. +11. Frecency is ``age_bucket_weight * (bonus / 100)`` + +When frecency for a page is calculated +-------------------------------------- + +Operations that may influence the frecency score are: + +* Adding visits +* Removing visits +* Adding bookmarks +* Removing bookmarks +* Changing the url of a bookmark + +Frecency is recalculated: + +* Immediately, when a new visit is added. The user expectation here is that the + page appears in search results after being visited. This is also valid for + any History API that allows to add visits. +* In background on idle times, in any other case. In most cases having a + temporarily stale value is not a problem, the main concern would be privacy + when removing history of a page, but removing whole history will either + completely remove the page or, if it's bookmarked, it will still be relevant. + In this case, when a change influencing frecency happens, the ``recalc_frecency`` + database field for the page is set to ``1``. + +Recalculation is done by the `PlacesFrecencyRecalculator <https://searchfox.org/mozilla-central/source/toolkit/components/places/PlacesFrecencyRecalculator.sys.mjs>`_ module. +The Recalculator is notified when ``PlacesUtils.history.shouldStartFrecencyRecalculation`` +value changes from false to true, that means there's values to recalculate. +A DeferredTask is armed, that will look for a user idle opportunity +in the next 5 minutes, otherwise it will run when that time elapses. +Once all the outdated values have been recalculated +``PlacesUtils.history.shouldStartFrecencyRecalculation`` is set back to false +until the next operation invalidating a frecency. +The recalculation task is also armed on the ``idle-daily`` notification. + +When the task is executed, it recalculates frecency of a chunk of pages. If +there are more pages left to recalculate, the task is re-armed. After frecency +of a page is recalculated, its ``recalc_frecency`` field is set back to ``0``. + +Frecency is also decayed daily during the ``idle-daily`` notification, by +multiplying all the scores by a decay rate of ``0.975`` (half-life of 28 days). +This guarantees entries not receiving new visits or bookmarks lose relevancy. + + +Adaptive Input History +====================== + +Input History (also known as Adaptive History) is a feature that allows to +find urls that the user previously picked. To do so, it associates search strings +with picked urls. + +Adaptive results are usually presented before frecency derived results, making +them appear as having an infinite frecency. + +When the user types a given string, and picks a result from the address bar, that +relation is stored and increases a use_count field for the given string. +The use_count field asymptotically approaches a max of ``10`` (the update is +done as ``use_count * .9 + 1``). + +On querying, all the search strings that start with the input string are matched, +a rank is calculated per each page as ``ROUND(MAX(use_count) * (1 + (input = :search_string)), 1)``, +so that results perfectly matching the search string appear at the top. +Results with the same rank are additionally sorted by descending frecency. + +On daily idles, when frecency is decayed, also input history gets decayed, in +particular the use_count field is multiplied by a decay rate of ``0.975``. +After decaying, any entry that has a ``use_count < 0.975^90 (= 0.1)`` is removed, +thus entries are removed if unused for 90 days. diff --git a/browser/components/urlbar/docs/telemetry.rst b/browser/components/urlbar/docs/telemetry.rst new file mode 100644 index 0000000000..dbc28db120 --- /dev/null +++ b/browser/components/urlbar/docs/telemetry.rst @@ -0,0 +1,769 @@ +Telemetry +========= + +This section describes existing telemetry probes measuring interaction with the +Address Bar. + +For telemetry specific to Firefox Suggest, see the +:doc:`firefox-suggest-telemetry` document. + +.. contents:: + :depth: 2 + + +Histograms +---------- + +PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This probe tracks the amount of time it takes to get the first result. + It is an exponential histogram with values between 5 and 100. + +PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This probe tracks the amount of time it takes to get the first six results. + It is an exponential histogram with values between 50 and 1000. + +FX_URLBAR_SELECTED_RESULT_METHOD +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This probe tracks how a result was picked by the user from the list. + It is a categorical histogram with these values: + + - ``enter`` + The user pressed Enter without selecting a result first. + This most likely happens when the user confirms the default preselected + result (aka *heuristic result*), or when they select with the keyboard a + one-off search button and confirm with Enter. + - ``enterSelection`` + The user selected a result, but not using Tab or the arrow keys, and then + pressed Enter. This is a rare and generally unexpected event, there may be + exotic ways to select a result we didn't consider, that are tracked here. + Look at arrowEnterSelection and tabEnterSelection for more common actions. + - ``click`` + The user clicked on a result. + - ``arrowEnterSelection`` + The user selected a result using the arrow keys, and then pressed Enter. + - ``tabEnterSelection`` + The first key the user pressed to select a result was the Tab key, and then + they pressed Enter. Note that this means the user could have used the arrow + keys after first pressing the Tab key. + - ``rightClickEnter`` + Before QuantumBar, it was possible to right-click a result to highlight but + not pick it. Then the user could press Enter. This is no more possible. + +FX_URLBAR_ZERO_PREFIX_DWELL_TIME_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This probe records the amount of time the zero-prefix view was shown; that is, + the time from when it was opened to the time it was closed. "Zero-prefix" + means the search string was empty, so the zero-prefix view is the view that's + shown when the user clicks in the urlbar before typing a search string. Often + it's also called the "top sites" view since normally it shows the user's top + sites. This is an exponential histogram whose values range from 0 to 60,000 + with 50 buckets. Values are in milliseconds. This histogram was introduced in + Firefox 110.0 in bug 1806765. + +PLACES_FRECENCY_RECALC_CHUNK_TIME_MS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This records the time necessary to recalculate frecency of a chunk of pages, + as defined in the `PlacesFrecencyRecalculator <https://searchfox.org/mozilla-central/source/toolkit/components/places/PlacesFrecencyRecalculator.sys.mjs>`_ module. + +Scalars +------- + +urlbar.abandonment +~~~~~~~~~~~~~~~~~~ + + A uint recording the number of abandoned engagements in the urlbar. An + abandonment occurs when the user begins using the urlbar but stops before + completing the engagement. This can happen when the user clicks outside the + urlbar to focus a different part of the window. It can also happen when the + user switches to another window while the urlbar is focused. + +urlbar.autofill_deletion +~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the deletion count for autofilled string in the urlbar. + This occurs when the user deletes whole autofilled string by BACKSPACE or + DELETE key while the autofilled string is selected. + +urlbar.engagement +~~~~~~~~~~~~~~~~~ + + A uint recording the number of engagements the user completes in the urlbar. + An engagement occurs when the user navigates to a page using the urlbar, for + example by picking a result in the urlbar panel or typing a search term or URL + in the urlbar and pressing the enter key. + +urlbar.impression.* +~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of impression that was displaying when user picks + any result. + + - ``autofill_about`` + For about-page type autofill. + - ``autofill_adaptive`` + For adaptive history type autofill. + - ``autofill_origin`` + For origin type autofill. + - ``autofill_other`` + Counts how many times some other type of autofill result that does not have + a specific scalar was shown. This is a fallback that is used when the code is + not properly setting a specific autofill type, and it should not normally be + used. If it appears in the data, it means we need to investigate and fix the + code that is not properly setting a specific autofill type. + - ``autofill_preloaded`` + For preloaded site type autofill. + - ``autofill_url`` + For url type autofill. + +urlbar.persistedsearchterms.revert_by_popup_count +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint that is incremented when search terms are persisted in the Urlbar and + the Urlbar is reverted to show a full URL due to a PopupNotification. This + can happen when a user is on a SERP and permissions are requested, e.g. + request access to location. If the popup is persistent and the user did not + dismiss it before switching tabs, the popup will reappear when they return to + the tab. Thus, when returning to the tab with the persistent popup, this + value will be incremented because it should have persisted search terms but + instead showed a full URL. + +urlbar.persistedsearchterms.view_count +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint that is incremented when search terms should be persisted in the + Urlbar. This will trigger when a user loads a SERP from any SAP that results + in the search terms persisting in the Urlbar, as well as switching to a tab + containing a SERP that should be persisting the search terms in the Urlbar, + regardless of whether a PopupNotification is present. Thus, for every + ``revert_by_popup_count``, there should be at least one corresponding + ``view_count``. + +urlbar.tips +~~~~~~~~~~~ + + This is a keyed scalar whose values are uints and are incremented each time a + tip result is shown, a tip is picked, and a tip's help button is picked. The + keys are: + + - ``intervention_clear-help`` + Incremented when the user picks the help button in the clear-history search + intervention. + - ``intervention_clear-picked`` + Incremented when the user picks the clear-history search intervention. + - ``intervention_clear-shown`` + Incremented when the clear-history search intervention is shown. + - ``intervention_refresh-help`` + Incremented when the user picks the help button in the refresh-Firefox + search intervention. + - ``intervention_refresh-picked`` + Incremented when the user picks the refresh-Firefox search intervention. + - ``intervention_refresh-shown`` + Incremented when the refresh-Firefox search intervention is shown. + - ``intervention_update_ask-help`` + Incremented when the user picks the help button in the update_ask search + intervention, which is shown when there's a Firefox update available but the + user's preference says we should ask them to download and apply it. + - ``intervention_update_ask-picked`` + Incremented when the user picks the update_ask search intervention. + - ``intervention_update_ask-shown`` + Incremented when the update_ask search intervention is shown. + - ``intervention_update_refresh-help`` + Incremented when the user picks the help button in the update_refresh search + intervention, which is shown when the user's browser is up to date but they + triggered the update intervention. We show this special refresh intervention + instead. + - ``intervention_update_refresh-picked`` + Incremented when the user picks the update_refresh search intervention. + - ``intervention_update_refresh-shown`` + Incremented when the update_refresh search intervention is shown. + - ``intervention_update_restart-help`` + Incremented when the user picks the help button in the update_restart search + intervention, which is shown when there's an update and it's been downloaded + and applied. The user needs to restart to finish. + - ``intervention_update_restart-picked`` + Incremented when the user picks the update_restart search intervention. + - ``intervention_update_restart-shown`` + Incremented when the update_restart search intervention is shown. + - ``intervention_update_web-help`` + Incremented when the user picks the help button in the update_web search + intervention, which is shown when we can't update the browser or possibly + even check for updates for some reason, so the user should download the + latest version from the web. + - ``intervention_update_web-picked`` + Incremented when the user picks the update_web search intervention. + - ``intervention_update_web-shown`` + Incremented when the update_web search intervention is shown. + - ``tabtosearch-shown`` + Increment when a non-onboarding tab-to-search result is shown, once per + engine per engagement. Please note that the number of times non-onboarding + tab-to-search results are picked is the sum of all keys in + ``urlbar.searchmode.tabtosearch``. Please also note that more detailed + telemetry is recorded about both onboarding and non-onboarding tab-to-search + results in urlbar.tabtosearch.*. These probes in ``urlbar.tips`` are still + recorded because ``urlbar.tabtosearch.*`` is not currently recorded + in Release. + - ``tabtosearch_onboard-shown`` + Incremented when a tab-to-search onboarding result is shown, once per engine + per engagement. Please note that the number of times tab-to-search + onboarding results are picked is the sum of all keys in + ``urlbar.searchmode.tabtosearch_onboard``. + - ``searchTip_onboard-picked`` + Incremented when the user picks the onboarding search tip. + - ``searchTip_onboard-shown`` + Incremented when the onboarding search tip is shown. + - ``searchTip_persist-picked`` + Incremented when the user picks the urlbar persisted search tip. + - ``searchTip_persist-shown`` + Incremented when the url persisted search tip is shown. + - ``searchTip_redirect-picked`` + Incremented when the user picks the redirect search tip. + - ``searchTip_redirect-shown`` + Incremented when the redirect search tip is shown. + +urlbar.searchmode.* +~~~~~~~~~~~~~~~~~~~ + + This is a set of keyed scalars whose values are uints incremented each + time search mode is entered in the Urlbar. The suffix on the scalar name + describes how search mode was entered. Possibilities include: + + - ``bookmarkmenu`` + Used when the user selects the Search Bookmarks menu item in the Library + menu. + - ``handoff`` + Used when the user uses the search box on the new tab page and is handed off + to the address bar. NOTE: This entry point was disabled from Firefox 88 to + 91. Starting with 91, it will appear but in low volume. Users must have + searching in the Urlbar disabled to enter search mode via handoff. + - ``keywordoffer`` + Used when the user selects a keyword offer result. + - ``oneoff`` + Used when the user selects a one-off engine in the Urlbar. + - ``shortcut`` + Used when the user enters search mode with a keyboard shortcut or menu bar + item (e.g. ``Accel+K``). + - ``tabmenu`` + Used when the user selects the Search Tabs menu item in the tab overflow + menu. + - ``tabtosearch`` + Used when the user selects a tab-to-search result. These results suggest a + search engine when the search engine's domain is autofilled. + - ``tabtosearch_onboard`` + Used when the user selects a tab-to-search onboarding result. These are + shown the first few times the user encounters a tab-to-search result. + - ``topsites_newtab`` + Used when the user selects a search shortcut Top Site from the New Tab Page. + - ``topsites_urlbar`` + Used when the user selects a search shortcut Top Site from the Urlbar. + - ``touchbar`` + Used when the user taps a search shortct on the Touch Bar, available on some + Macs. + - ``typed`` + Used when the user types an engine alias in the Urlbar. + - ``historymenu`` + Used when the user selects the Search History menu item in a History + menu. + - ``other`` + Used as a catchall for other behaviour. We don't expect this scalar to hold + any values. If it does, we need to correct an issue with search mode entry + points. + + The keys for the scalars above are engine and source names. If the user enters + a remote search mode with a built-in engine, we record the engine name. If the + user enters a remote search mode with an engine they installed (e.g. via + OpenSearch or a WebExtension), we record ``other`` (not to be confused with + the ``urlbar.searchmode.other`` scalar above). If they enter a local search + mode, we record the English name of the result source (e.g. "bookmarks", + "history", "tabs"). Note that we slightly modify the engine name for some + built-in engines: we flatten all localized Amazon sites (Amazon.com, + Amazon.ca, Amazon.de, etc.) to "Amazon" and we flatten all localized + Wikipedia sites (Wikipedia (en), Wikipedia (fr), etc.) to "Wikipedia". This + is done to reduce the number of keys used by these scalars. + +urlbar.picked.* +~~~~~~~~~~~~~~~ + + This is a set of keyed scalars whose values are uints incremented each + time a result is picked from the Urlbar. The suffix on the scalar name + is the result type. The keys for the scalars above are the 0-based index of + the result in the urlbar panel when it was picked. + + .. note:: + Available from Firefox 84 on. Use the *FX_URLBAR_SELECTED_** histograms in + earlier versions. + + .. note:: + Firefox 102 deprecated ``autofill`` and added ``autofill_about``, + ``autofill_adaptive``, ``autofill_origin``, ``autofill_other``, + ``autofill_preloaded``, and ``autofill_url``. + + Valid result types are: + + - ``autofill`` + This scalar was deprecated in Firefox 102 and replaced with + ``autofill_about``, ``autofill_adaptive``, ``autofill_origin``, + ``autofill_other``, ``autofill_preloaded``, and ``autofill_url``. Previously + it was recorded in each of the cases that the other scalars now cover. + - ``autofill_about`` + An autofilled "about:" page URI (e.g., about:config). The user must first + type "about:" to trigger this type of autofill. + - ``autofill_adaptive`` + An autofilled URL from the user's adaptive history. This type of autofill + differs from ``autofill_url`` in two ways: (1) It's based on the user's + adaptive history, a particular type of history that associates the user's + search string with the URL they pick in the address bar. (2) It autofills + full URLs instead of "up to the next slash" partial URLs. For more + information on this type of autofill, see this `adaptive history autofill + document`_. + - ``autofill_origin`` + An autofilled origin_ from the user's history. Typically "origin" means a + domain or host name like "mozilla.org". Technically it can also include a + URL scheme or protocol like "https" and a port number like ":8000". Firefox + can autofill domain names by themselves, domain names with schemes, domain + names with ports, and domain names with schemes and ports. All of these + cases count as origin autofill. For more information, see this `adaptive + history autofill document`_. + - ``autofill_other`` + Counts how many times some other type of autofill result that does not have + a specific keyed scalar was picked at a given index. This is a fallback that + is used when the code is not properly setting a specific autofill type, and + it should not normally be used. If it appears in the data, it means we need + to investigate and fix the code that is not properly setting a specific + autofill type. + - ``autofill_preloaded`` + An autofilled `preloaded site`_. The preloaded-sites feature (as it relates + to this telemetry scalar) has never been enabled in Firefox, so this scalar + should never be recorded. It can be enabled by flipping a hidden preference, + however. It's included here for consistency and correctness. + - ``autofill_url`` + An autofilled URL or partial URL from the user's history. Firefox autofills + URLs "up to the next slash", so to trigger URL autofill, the user must first + type a domain name (or trigger origin autofill) and then begin typing the + rest of the URL (technically speaking, its path). As they continue typing, + the URL will only be partially autofilled up to the next slash, or if there + is no next slash, to the end of the URL. This allows the user to easily + visit different subpaths of a domain. For more information, see this + `adaptive history autofill document`_. + - ``bookmark`` + A bookmarked URL. + - ``dynamic`` + A specially crafted result, often used in experiments when basic types are + not flexible enough for a rich layout. + - ``dynamic_wikipedia`` + A dynamic Wikipedia Firefox Suggest result. + - ``extension`` + Added by an add-on through the omnibox WebExtension API. + - ``formhistory`` + A search suggestion from previous search history. + - ``history`` + A URL from history. + - ``keyword`` + A bookmark keyword. + - ``navigational`` + A navigational suggestion Firefox Suggest result. + - ``quickaction`` + A QuickAction. + - ``quicksuggest`` + A Firefox Suggest (a.k.a. quick suggest) suggestion. + - ``remotetab`` + A tab synced from another device. + - ``searchengine`` + A search result, but not a suggestion. May be the default search action + or a search alias. + - ``searchsuggestion`` + A remote search suggestion. + - ``switchtab`` + An open tab. + - ``tabtosearch`` + A tab to search result. + - ``tip`` + A tip result. + - ``topsite`` + An entry from top sites. + - ``trending`` + A trending suggestion. + - ``unknown`` + An unknown result type, a bug should be filed to figure out what it is. + - ``visiturl`` + The user typed string can be directly visited. + - ``weather`` + A Firefox Suggest weather suggestion. + + .. _adaptive history autofill document: https://docs.google.com/document/d/e/2PACX-1vRBLr_2dxus-aYhZRUkW9Q3B1K0uC-a0qQyE3kQDTU3pcNpDHb36-Pfo9fbETk89e7Jz4nkrqwRhi4j/pub + .. _origin: https://html.spec.whatwg.org/multipage/origin.html#origin + .. _preloaded site: https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarProviderPreloadedSites.jsm + +urlbar.picked.searchmode.* +~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This is a set of keyed scalars whose values are uints incremented each time a + result is picked from the Urlbar while the Urlbar is in search mode. The + suffix on the scalar name is the search mode entry point. The keys for the + scalars are the 0-based index of the result in the urlbar panel when it was + picked. + + .. note:: + These scalars share elements of both ``urlbar.picked.*`` and + ``urlbar.searchmode.*``. Scalar name suffixes are search mode entry points, + like ``urlbar.searchmode.*``. The keys for these scalars are result indices, + like ``urlbar.picked.*``. + + .. note:: + These data are a subset of the data recorded by ``urlbar.picked.*``. For + example, if the user enters search mode by clicking a one-off then selects + a Google search suggestion at index 2, we would record in **both** + ``urlbar.picked.searchsuggestion`` and ``urlbar.picked.searchmode.oneoff``. + +urlbar.tabtosearch.* +~~~~~~~~~~~~~~~~~~~~ + + This is a set of keyed scalars whose values are uints incremented when a + tab-to-search result is shown, once per engine per engagement. There are two + sub-probes: ``urlbar.tabtosearch.impressions`` and + ``urlbar.tabtosearch.impressions_onboarding``. The former records impressions + of regular tab-to-search results and the latter records impressions of + onboarding tab-to-search results. The key values are identical to those of the + ``urlbar.searchmode.*`` probes: they are the names of the engines shown in the + tab-to-search results. Engines that are not built in are grouped under the + key ``other``. + + .. note:: + Due to the potentially sensitive nature of these data, they are currently + collected only on pre-release version of Firefox. See bug 1686330. + +urlbar.zeroprefix.abandonment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of abandonments of the zero-prefix view. + "Zero-prefix" means the search string was empty, so the zero-prefix view is + the view that's shown when the user clicks in the urlbar before typing a + search string. Often it's called the "top sites" view since normally it shows + the user's top sites. "Abandonment" means the user opened the zero-prefix view + but it was closed without the user picking a result inside it. This scalar was + introduced in Firefox 110.0 in bug 1806765. + +urlbar.zeroprefix.engagement +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of engagements in the zero-prefix view. + "Zero-prefix" means the search string was empty, so the zero-prefix view is + the view that's shown when the user clicks in the urlbar before typing a + search string. Often it's called the "top sites" view since normally it shows + the user's top sites. "Engagement" means the user picked a result inside the + view. This scalar was introduced in Firefox 110.0 in bug 1806765. + +urlbar.zeroprefix.exposure +~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of times the user was exposed to the zero-prefix + view; that is, the number of times it was shown. "Zero-prefix" means the + search string was empty, so the zero-prefix view is the view that's shown when + the user clicks in the urlbar before typing a search string. Often it's called + the "top sites" view since normally it shows the user's top sites. This scalar + was introduced in Firefox 110.0 in bug 1806765. + +urlbar.quickaction.impression +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of times the user was shown a quickaction, the + key is in the form $key-$n where $n is the number of characters the user typed + in order for the suggestion to show. See bug 1806024. + +urlbar.quickaction.picked +~~~~~~~~~~~~~~~~~~~~~~~~~ + + A uint recording the number of times the user selected a quickaction, the + key is in the form $key-$n where $n is the number of characters the user typed + in order for the suggestion to show. See bug 1783155. + +places.* +~~~~~~~~ + + This is Places related telemetry. + + Valid result types are: + + - ``sponsored_visit_no_triggering_url`` + Number of sponsored visits that could not find their triggering URL in + history. We expect this to be a small number just due to the navigation layer + manipulating URLs. A large or growing value may be a concern. + - ``pages_need_frecency_recalculation`` + Number of pages in need of a frecency recalculation. This number should + remain small compared to the total number of pages in the database (see the + `PLACES_PAGES_COUNT` histogram). It can be used to valuate the frequency + and size of recalculations, for performance reasons. + +Search Engagement Telemetry +--------------------------- + +The search engagement telemetry provided since Firefox 110 is is recorded using +Glean events. Because of the data size, these events are collected only for a +subset of the population, using the Glean Sampling feature. Please see the +following documents for the details. + + - `Engagement`_ : + It is defined as a completed action in urlbar, where a user picked one of + the results. + - `Abandonment`_ : + It is defined as an action where the user open the results but does not + complete an engagement action, usually unfocusing the urlbar. This also + happens when the user switches to another window, if the results popup was + opening. + - `Impression`_ : + It is defined as an action where the results had been shown to the user for + a while. In default, it will be recorded when the same results have been + shown and 1 sec has elapsed. The interval value can be modified through the + `browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs` + preference. + +.. _Engagement: https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_engagement +.. _Abandonment: https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_abandonment +.. _Impression: https://dictionary.telemetry.mozilla.org/apps/firefox_desktop/metrics/urlbar_impression + + +Custom pings for Contextual Services +------------------------------------ + +Contextual Services currently has two features involving the address bar, top +sites and Firefox Suggest. Top sites telemetry is described below. For Firefox +Suggest, see the :doc:`firefox-suggest-telemetry` document. + +Firefox sends the following `custom pings`_ to record impressions and clicks of +the top sites feature. + + .. _custom pings: https://docs.telemetry.mozilla.org/cookbooks/new_ping.html#sending-a-custom-ping + +Top Sites Impression +~~~~~~~~~~~~~~~~~~~~ + + This records an impression when a sponsored top site is shown. + + - ``context_id`` + A UUID representing this user. Note that it's not client_id, nor can it be + used to link to a client_id. + - ``tile_id`` + A unique identifier for the sponsored top site. + - ``source`` + The browser location where the impression was displayed. + - ``position`` + The placement of the top site (1-based). + - ``advertiser`` + The Name of the advertiser. + - ``reporting_url`` + The reporting URL of the sponsored top site, normally pointing to the ad + partner's reporting endpoint. + - ``version`` + Firefox version. + - ``release_channel`` + Firefox release channel. + - ``locale`` + User's current locale. + +Changelog + Firefox 108.0 + The impression ping is sent for Pocket sponsored tiles as well. Pocket sponsored tiles have different values for ``advertiser`` and ``reporting_url`` is null. [Bug 1794022_] + + Firefox 87.0 + Introduced. [Non_public_doc_] + +.. _Non_public_doc: https://docs.google.com/document/d/1qLb4hUwR8YQj5QnjJtwxQIoDCPLQ6XuAmJPQ6_WmS4E/edit +.. _1794022: https://bugzilla.mozilla.org/show_bug.cgi?id=1794022 + +Top Sites Click +~~~~~~~~~~~~~~~ + + This records a click ping when a sponsored top site is clicked by the user. + + - ``context_id`` + A UUID representing this user. Note that it's not client_id, nor can it be + used to link to a client_id. + - ``tile_id`` + A unique identifier for the sponsored top site. + - ``source`` + The browser location where the click was tirggered. + - ``position`` + The placement of the top site (1-based). + - ``advertiser`` + The Name of the advertiser. + - ``reporting_url`` + The reporting URL of the sponsored top site, normally pointing to the ad + partner's reporting endpoint. + - ``version`` + Firefox version. + - ``release_channel`` + Firefox release channel. + - ``locale`` + User's current locale. + +Changelog + Firefox 108.0 + The click ping is sent for Pocket sponsored tiles as well. Pocket sponsored tiles have different values for ``advertiser`` and ``reporting_url`` is null. [Bug 1794022_] + + Firefox 87.0 + Introduced. [Non_public_doc_] + + +Other telemetry relevant to the Address Bar +------------------------------------------- + +Search Telemetry +~~~~~~~~~~~~~~~~ + + Some of the `search telemetry`_ is also relevant to the address bar. + +contextual.services.topsites.* +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + These keyed scalars instrument the impressions and clicks for sponsored top + sites in the urlbar. + The key is a combination of the source and the placement of the top sites link + (1-based) such as 'urlbar_1'. For each key, it records the counter of the + impression or click. + Note that these scalars are shared with the top sites on the newtab page. + +Telemetry Environment +~~~~~~~~~~~~~~~~~~~~~ + + The following preferences relevant to the address bar are recorded in + :doc:`telemetry environment data </toolkit/components/telemetry/data/environment>`: + + - ``browser.search.suggest.enabled``: The global toggle for search + suggestions everywhere in Firefox (search bar, urlbar, etc.). Defaults to + true. + - ``browser.urlbar.autoFill``: The global preference for whether autofill in + the urlbar is enabled. When false, all types of autofill are disabled. + - ``browser.urlbar.autoFill.adaptiveHistory.enabled``: True if adaptive + history autofill in the urlbar is enabled. + - ``browser.urlbar.suggest.searches``: True if search suggestions are + enabled in the urlbar. Defaults to false. + +Firefox Suggest +~~~~~~~~~~~~~~~ + + Telemetry specific to Firefox Suggest is described in the + :doc:`firefox-suggest-telemetry` document. + +.. _search telemetry: /browser/search/telemetry.html + +Event Telemetry +--------------- + + .. note:: + This is a legacy event telemetry. For the current telemetry, please see + `Search Engagement Telemetry`_. These legacy events were disabled by default + and required enabling through a preference or a Urlbar WebExtension + experimental API. + +.. _Search Engagement Telemetry: #search-engagement-telemetry + +The event telemetry is grouped under the ``urlbar`` category. + +Event Method +~~~~~~~~~~~~ + + There are two methods to describe the interaction with the urlbar: + + - ``engagement`` + It is defined as a completed action in urlbar, where a user inserts text + and executes one of the actions described in the Event Object. + - ``abandonment`` + It is defined as an action where the user inserts text but does not + complete an engagement action, usually unfocusing the urlbar. This also + happens when the user switches to another window, regardless of urlbar + focus. + +Event Value +~~~~~~~~~~~ + + This is how the user interaction started + + - ``typed``: The text was typed into the urlbar. + - ``dropped``: The text was drag and dropped into the urlbar. + - ``pasted``: The text was pasted into the urlbar. + - ``topsites``: The user opened the urlbar view without typing, dropping, + or pasting. + In these cases, if the urlbar input is showing the URL of the loaded page + and the user has not modified the input’s content, the urlbar views shows + the user’s top sites. Otherwise, if the user had modified the input’s + content, the urlbar view shows results based on what the user has typed. + To tell whether top sites were shown, it's enough to check whether value is + ``topsites``. To know whether the user actually picked a top site, check + check that ``numChars`` == 0. If ``numChars`` > 0, the user initially opened + top sites, but then they started typing and confirmed a different result. + - ``returned``: The user abandoned a search, for example by switching to + another tab/window, or focusing something else, then came back to it + and continued. We consider a search continued if the user kept at least the + first char of the original search string. + - ``restarted``: The user abandoned a search, for example by switching to + another tab/window, or focusing something else, then came back to it, + cleared it and then typed a new string. + +Event Object +~~~~~~~~~~~~ + + These describe actions in the urlbar: + + - ``click`` + The user clicked on a result. + - ``enter`` + The user confirmed a result with Enter. + - ``drop_go`` + The user dropped text on the input field. + - ``paste_go`` + The user used Paste and Go feature. It is not the same as paste and Enter. + - ``blur`` + The user unfocused the urlbar. This is only valid for ``abandonment``. + +Event Extra +~~~~~~~~~~~ + + This object contains additional information about the interaction. + Extra is a key-value store, where all the keys and values are strings. + + - ``elapsed`` + Time in milliseconds from the initial interaction to an action. + - ``numChars`` + Number of input characters the user typed or pasted at the time of + submission. + - ``numWords`` + Number of words in the input. The measurement is taken from a trimmed input + split up by its spaces. This is not a perfect measurement, since it will + return an incorrect value for languages that do not use spaces or URLs + containing spaces in its query parameters, for example. + - ``selType`` + The type of the selected result at the time of submission. + This is only present for ``engagement`` events. + It can be one of: ``none``, ``autofill``, ``visiturl``, ``bookmark``, + ``history``, ``keyword``, ``searchengine``, ``searchsuggestion``, + ``switchtab``, ``remotetab``, ``extension``, ``oneoff``, ``keywordoffer``, + ``canonized``, ``tip``, ``tiphelp``, ``formhistory``, ``tabtosearch``, + ``help``, ``block``, ``quicksuggest``, ``unknown`` + In practice, ``tabtosearch`` should not appear in real event telemetry. + Opening a tab-to-search result enters search mode and entering search mode + does not currently mark the end of an engagement. It is noted here for + completeness. Similarly, ``block`` indicates a result was blocked or deleted + but should not appear because blocking a result does not end the engagement. + - ``selIndex`` + Index of the selected result in the urlbar panel, or -1 for no selection. + There won't be a selection when a one-off button is the only selection, and + for the ``paste_go`` or ``drop_go`` objects. There may also not be a + selection if the system was busy and results arrived too late, then we + directly decide whether to search or visit the given string without having + a fully built result. + This is only present for ``engagement`` events. + - ``provider`` + The name of the result provider for the selected result. Existing values + are: ``HeuristicFallback``, ``Autofill``, ``Places``, + ``TokenAliasEngines``, ``SearchSuggestions``, ``UrlbarProviderTopSites``. + Data from before Firefox 91 will also list ``UnifiedComplete`` as a + provider. This is equivalent to ``Places``. + Values can also be defined by `URLBar provider experiments`_. + + .. _URLBar provider experiments: experiments.html#developing-address-bar-extensions diff --git a/browser/components/urlbar/docs/testing.rst b/browser/components/urlbar/docs/testing.rst new file mode 100644 index 0000000000..a56bd297a7 --- /dev/null +++ b/browser/components/urlbar/docs/testing.rst @@ -0,0 +1,216 @@ +Testing +======= +This documentation discusses how to write a test for the address bar and the +different test utilities that are useful when writing a test for the address +bar. + +Common Tests +------------ +Mochitests +~~~~~~~~~~ +Some common tests for the address bar are the **mochitests**. The purpose of +a mochitest is to run the browser itself. Mochitests can be called +"browser tests", "mochitest-browser-chrome", or +"browser-chrome-mochitests". There are other types of mochitests that are not +for testing the browser and therefore can be ignored for the purpose of the +address bar. An example of a mochitest is +`tests/browser/browser_switchTab_currentTab.js <https://searchfox.org/mozilla- +central/source/browser/components/urlbar/tests/browser/browser_switchTab_ +currentTab.js>`_ + +XPCShell +~~~~~~~~ +`XPCShell Tests <https://firefox-source-docs.mozilla.org/testing/xpcshell/index +.html>`_ are another type of test relevant to the address bar. XPCShell tests +are often called unit tests because they tend to test specific modules or +components in isolation, as opposed the mochitest which have access to the full +browser chrome. + +XPCShell tests do not use the browser UI and are completely separate from +browser chrome. XPCShell tests are executed in a JavaScript shell that is +outside of the browser. For historical context, the "XPC" naming convention is +from XPCOM (Cross Platform Component Model) which is an older framework that +allows programmers to write custom functions in one language, such as C++, and +connect it to other components in another language, such as JavaScript. + +Each XPCShell test is executed in a new shell instance, therefore you will +see several Firefox icons pop up and close when XPCShell tests are executing. +These are two examples of XPCShell tests for the address bar +`test_providerHeuristicFallback <https://searchfox.org/mozilla-central/source +/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js>`_ +and +`test_providerTabToSearch <https://searchfox.org/mozilla-central/source/browser +/components/urlbar/tests/unit/test_providerTabToSearch.js>`_. + +When To Write a XPCShell or Mochitest? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Always default to writing an XPCShell test if it is possible. XPCShell +tests are faster to execute than browser tests. Although, most of the time you +will write a browser test because you could be modifying something in the UI or +testing a specific component in the UI. + +If you are writing a test for a urlbarProvider, you can test the Provider +through a XPCShell test. Providers do not modify the UI, instead what they do is +receive a url string query, search for the string and bring back the result. An +example is the `ProviderPlaces <https://searchfox.org/mozilla-central/sou +rce/browser/components/urlbar/UrlbarProviderPlaces.jsm>`_, which fetches +results from the Places database. Another component that’s good for writing +XPCShell test is the `urlbarMuxer <https://searchfox.org/mozilla-central/ +source/browser/components/urlbar/UrlbarMuxerUnifiedComplete.jsm>`_. + +There may be times where writing both an XPCShell test and browser test is +necessary. In these situations, you could be testing the result from a Provider +and also testing what appears in the UI is correct. + +How To Write a Test +------------------- + +Test Boilerplate +~~~~~~~~~~~~~~~~ +This basic test boilerplate includes a license code at the top and this license +code is present at the top of every test file, the ``"use strict"`` string is +to enable strict mode in JavaScript, and ``add_task`` function adds tests to be +executed by the test harness. + +.. code-block:: javascript + + /* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + + /** + * This tests ensures that the urlbar ... + */ + + "use strict"; + + add_task(async function testOne() { + // testing code and assertions + }); + + add_task(async function testTwo() { + // testing code and assertions + }); + +In order to run a test use the ``./mach`` command, for example, ``./mach test <path to test +file>`` to run test locally. Use the command with ``--jsdebugger`` argument at +the end to open the DevTools debugger to step through the test, ``./mach test +<path to test> --jsdebugger``. + +Manifest +~~~~~~~~ +The manifest's purpose is to list all the test in the directory and dictate to +the test harness which files are test and how the test harness should run these +test. Anytime a test is created, the test file name needs to be added to the +manifest in alphabetical order. + +Start in the manifest file and add your test name in alphabetical +order. The manifest file we should add our test in is +`browser.ini <https://searchfox.org/mozilla-central/source/browser/components/ +urlbar/tests/browser/browser.ini>`_. The ``urlbar/test/browser/`` directory +is the main browser test directory for address bar, and the manifest file +linked above is the main browser test manifest. +The ``.ini`` file extension is an initialization file for Windows or MS-DOS. + +Manifest Metadata +~~~~~~~~~~~~~~~~~ +The manifest file can define common keys/metadata to influence the test's +behavior. For example, the metadata ``support-files`` are a list of additional +files required to run a test. Any values assigned to the key ``support-files`` +only applies to the single file directly above the ``support-files`` key. +If more files require ``support-files``, then ``support-files`` need to be +added directly under the other test file names. Another example of a manifest +metadata is ``[DEFAULT]``. Anything under ``[DEFAULT]`` will be picked up by +all tests in the manifest file. + +For information on all the manifest metadata available, please visit +:doc:`/build/buildsystem/test_manifests`. + +Common Test Utilities +--------------------- +This section describes common test utilities which may be useful when writing a +test for the address bar. Below are a description of common utils where you can +find helpful testing methods. + +Many test utils modules end with ``TestUtils.jsm``. However not every testing +function will end with ``TestUtils.jsm``. For example, `PlacesUtils <https:// +searchfox.org/mozilla-central/source/toolkit/components/places/PlacesUtils. +jsm>`_ does not have “Test” within its name. + +A critical function to remember is the ``registerCleanupFunction`` within +the ``head.js`` file mentioned below. This function's purpose may be to clean +up the history or any other clean ups that are necessary after your test is +complete. Cleaning up after a browser test is necessary because clean up +ensures what is done within one test will not affect subsequent tests. + +head.js and common-head.js +~~~~~~~~~~~~~~~~~~~~~~~~~~ +The `head.js <https://searchfox.org/mozilla-central/source/browser/components +/urlbar/tests/browser/head.js>`_ file is executed at the beginning before each +test and contains imports to modules which are useful for each test. +Any tasks ``head.js`` adds (via add_task) will run first for each test, and +any variables and functions it defines will be available in the scope of +each test. This file is small because most of our Utils are actually in other +`.jsm` files. + +The ``XPCOMUtils.defineLazyModuleGetters`` method within ``head.js`` sets up +modules names to where they can be found, their paths. ``Lazy`` means the files +are only imported if or when it is used. Any tests in this directory can use +these modules without importing it themselves in their own file. +The ``head.js`` provides a convenience for this purpose. The ``head.js`` file +imports `common-head.js <https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/browser/head-common.js>`_ +making everything within ``head-common.js`` available in ``head.js`` as well. + +The ``registerCleanupFunction`` is an important function in browser mochi tests +and it is part of the test harness. This function registers a callback function +to be executed when your test is complete. The purpose may be to clean up the +history or any other clean ups that are necessary after your test is complete. +For example, browser mochi tests are executed one after the other in the same +window instance. The global object in each test is the browser ``window`` +object, for example, each test script runs in the browser window. +If the history is not cleaned up it will remain and may affect subsequent +browser tests. For most test outside of address bar, you may not need to clear +history. In addition to cleanup, ``head.js`` calls the +``registerCleanupFunction`` to ensure the urlbar panel is closed after each +test. + +UrlbarTestUtils +~~~~~~~~~~~~~~~ +`UrlbarTestUtils.sys.mjs <https://searchfox.org/mozilla-central/source/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs>`_ is useful for url bar testing. This +file contains methods that can help with starting a new search in the url bar, +waiting for a new search to complete, returning the results in +the view, and etc. + +BrowserTestUtils +~~~~~~~~~~~~~~~~ +`BrowserTestUtils.sys.mjs <../../testing/browser-chrome/browsertestutils.html>`_ +is useful for browser window testing. This file contains methods that can help +with opening tabs, waiting for certain events to happen in the window, opening +new or private windows, and etc. + +TestUtils +~~~~~~~~~ +`TestUtils.jsm <../../testing/testutils.html>`_ is useful for general +purpose testing and does not depend on the browser window. This file contains +methods that are useful when waiting for a condition to return true, waiting for +a specific preference to change, and etc. + +PlacesTestUtils +~~~~~~~~~~~~~~~ +:searchfox:`PlacesTestUtils.sys.mjs <toolkit/components/places/tests/PlacesTestU +tils.sys.mjs>` is useful for adding visits, adding +bookmarks, waiting for notification of visited pages, and etc. + +EventUtils +~~~~~~~~~~ +`EventUtils.js <https://searchfox.org/mozilla-central/source/testing/mochitest +/tests/SimpleTest/EventUtils.js>`_ is an older test file and does not +need to be imported because it is not a ``.jsm`` file. ``EventUtils`` is only +used for browser tests, unlike the other TestUtils listed above which are +used for browser tests, XPCShell tests and other tests. + +All the functions within ``EventUtils.js`` are automatically available in +browser tests. This file contains functions that are useful for synthesizing +mouse clicks and keypresses. Some commonly used functions are +``synthesizeMouseAtCenter`` which places the mouse at the center of the DOM +element and ``synthesizeKey`` which can be used to navigate the view and start +a search by using keydown and keyenter arguments. diff --git a/browser/components/urlbar/docs/utilities.rst b/browser/components/urlbar/docs/utilities.rst new file mode 100644 index 0000000000..8fa3b8509e --- /dev/null +++ b/browser/components/urlbar/docs/utilities.rst @@ -0,0 +1,26 @@ +Utilities +========= + +Various modules provide shared utilities to the other components: + +`UrlbarPrefs.jsm <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarPrefs.jsm>`_ +------------------------------------------------------------------------------------------------------------- + +Implements a Map-like storage or urlbar related preferences. The values are kept +up-to-date. + +.. highlight:: JavaScript +.. code:: + + // Always use browser.urlbar. relative branch, except for the preferences in + // PREF_OTHER_DEFAULTS. + UrlbarPrefs.get("delay"); // Gets value of browser.urlbar.delay. + +.. note:: + + Newly added preferences should always be properly documented in UrlbarPrefs. + +`UrlbarUtils.jsm <https://searchfox.org/mozilla-central/source/browser/components/urlbar/UrlbarUtils.jsm>`_ +------------------------------------------------------------------------------------------------------------- + +Includes shared utils and constants shared across all the components. diff --git a/browser/components/urlbar/jar.mn b/browser/components/urlbar/jar.mn new file mode 100644 index 0000000000..1060aed4bc --- /dev/null +++ b/browser/components/urlbar/jar.mn @@ -0,0 +1,12 @@ +# 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/. + +browser.jar: + content/browser/urlbar/preloaded-top-urls.json (content/preloaded-top-urls.json) + content/browser/urlbar/quicksuggestOnboarding.html (content/quicksuggestOnboarding.html) + content/browser/urlbar/quicksuggestOnboarding.js (content/quicksuggestOnboarding.js) + content/browser/urlbar/quicksuggestOnboarding.css (content/quicksuggestOnboarding.css) + content/browser/urlbar/quicksuggestOnboarding_magglass_animation.svg (content/quicksuggestOnboarding_magglass_animation.svg) + content/browser/urlbar/quicksuggestOnboarding_magglass.svg (content/quicksuggestOnboarding_magglass.svg) + content/browser/urlbar/suggest-example.svg (content/suggest-example.svg) diff --git a/browser/components/urlbar/metrics.yaml b/browser/components/urlbar/metrics.yaml new file mode 100644 index 0000000000..1f4a8b1318 --- /dev/null +++ b/browser/components/urlbar/metrics.yaml @@ -0,0 +1,547 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - "Firefox :: Address Bar" + +urlbar: + abandonment: + disabled: true + type: event + description: Recorded when the user abandons a search (blurring the urlbar). + extra_keys: + sap: + description: > + `sap` is the meaning of `search access point`. It records where the + user started the search action from. The possible values are: `urlbar` + , `handoff`, `urlbar_newtab` and `urlbar_addonpage`. + type: string + interaction: + description: > + How the user started the search action. The possible values are: + `typed`, `pasted`, `topsite_search` (clicked on a topsite search + shortcut), `topsites` (selected a topsite result with empty search + string), `returned` (The user abandoned a search, then returned to it) + , `restarted` (The user abandoned a search, then returned to it, + cleared it and typed a completely different string), `refined` (The + user abandoned a search, then returned to it, and partially modified + the string), `persisted_search_terms` (The user returned to a previous + successful search that persisted terms in the urlbar), + `persisted_search_terms_restarted` (The user returned to a previous + successful search that persisted terms in the urlbar, then cleared it + and typed a completely different string) and + `persisted_search_terms_refined` (The user returned to a previous + successful search that persisted terms in the urlbar, and partially + modified the string). + type: string + search_mode: + description: > + If the urlbar is in search mode, thus restricting results to a + specific search engine or local source, this is set to the search mode + source. The possible sources are: `actions`, `bookmarks`, `history`, + `search_engine`, and `tabs`. If search mode is active but the source + did not fall into any of these categories, this will be `unknown`. If + search mode is not active, this will be an empty string. + type: string + n_chars: + description: > + The length of string used for the search. It includes whitespaces. + type: quantity + n_words: + description: > + The length of words used for the search. The words are made by + splitting the search string by whitespaces, thus this doesn’t support + CJK languages. For performance reasons a maximum of 255 characters are + considered when splitting. + type: quantity + n_results: + description: > + The number of results shown to the user. If this is high the results + list below may be truncated due to technical limitations. Also note in + that case not all the results may be physically visible due to the + screen size limitation. + type: quantity + groups: + description: > + Comma separated list of result groups in the order they were shown to + the user. The groups may be repeated, since the list will match 1:1 + the results list, so we can link each result to a group. The possible + group names are: `heuristic`, `adaptive_history`, `search_history`, + `search_suggest`, `search_suggest_rich`, `trending_search`, + `trending_search_rich`, `top_pick`, `top_site`, `remote_tab`, + `addon`, `general`, `suggest`, `about_page` and `suggested_index`. If + the group did not fall into any of these, this will be `unknown` and + a bug should be filed to investigate it. + type: string + results: + description: > + Comma separated list of result types in the order they were shown to + the user. The `unknown` type should not occur and indicates a bug. The + possible types are: + `action`, + `addon`, + `autofill_about`, + `autofill_adaptive`, + `autofill_origin`, + `autofill_unknown`, + `autofill_url`, + `bookmark`, + `calc`, + `history`, + `intervention_clear`, + `intervention_refresh`, + `intervention_unknown`, + `intervention_update`, + `keyword`, + `merino_adm_nonsponsored`, + `merino_adm_sponsored`, + `merino_amo`, + `merino_top_picks`, + `merino_wikipedia`, + `remote_tab`, + `rs_adm_nonsponsored`, + `rs_adm_sponsored`, + `search_engine`, + `search_history`, + `search_suggest`, + `search_suggest_rich`, + `suggest_non_sponsor`, + `suggest_sponsor`, + `tab`, + `tab_to_search`, + `tip_dismissal_acknowledgment`, + `tip_onboard`, + `tip_persist`, + `tip_redirect`, + `tip_unknown`, + `top_site`, + `trending_search`, + `trending_search_rich`, + `unit`, + `url`, + `weather` + type: string + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800414 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800414#c2 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717#c4 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + engagement: + disabled: true + type: event + description: Recorded when the user executes an action on a result. + extra_keys: + sap: + description: > + `sap` is the meaning of `search access point`. It records where the + user started the search action from. The possible values are: `urlbar` + , `handoff`, `urlbar_newtab` and `urlbar_addonpage`. + type: string + interaction: + description: > + How the user started the search action. The possible values are: + `typed`, `pasted`, `topsite_search` (clicked on a topsite search + shortcut), `topsites` (selected a topsite result with empty search + string), `returned` (The user abandoned a search, then returned to it) + , `restarted` (The user abandoned a search, then returned to it, + cleared it and typed a completely different string), `refined` (The + user abandoned a search, then returned to it, and partially modified + the string), `persisted_search_terms` (The user returned to a previous + successful search that persisted terms in the urlbar), + `persisted_search_terms_restarted` (The user returned to a previous + successful search that persisted terms in the urlbar, then cleared it + and typed a completely different string) and + `persisted_search_terms_refined` (The user returned to a previous + successful search that persisted terms in the urlbar, and partially + modified the string). + type: string + search_mode: + description: > + If the urlbar is in search mode, thus restricting results to a + specific search engine or local source, this is set to the search mode + source. The possible sources are: `actions`, `bookmarks`, `history`, + `search_engine`, and `tabs`. If search mode is active but the source + did not fall into any of these categories, this will be `unknown`. If + search mode is not active, this will be an empty string. + type: string + n_chars: + description: > + The length of string used for the search. It includes whitespaces. + type: quantity + n_words: + description: > + The length of words used for the search. The words are made by + splitting the search string by whitespaces, thus this doesn’t support + CJK languages. For performance reasons a maximum of 255 characters are + considered when splitting. + type: quantity + n_results: + description: > + The number of results shown to the user. If this is high the results + list below may be truncated due to technical limitations. Also note in + that case not all the results may be physically visible due to the + screen size limitation. + type: quantity + selected_result: + description: > + The type of the result the user selected. The `unknown` type should + not occur and indicates a bug. The possible types are: + `action`, + `addon`, + `autofill_about`, + `autofill_adaptive`, + `autofill_origin`, + `autofill_unknown`, + `autofill_url`, + `bookmark`, + `calc`, + `experimental_addon`, + `history`, + `input_field`, + `intervention_clear`, + `intervention_refresh`, + `intervention_unknown`, + `intervention_update`, + `keyword`, + `merino_adm_nonsponsored`, + `merino_adm_sponsored`, + `merino_amo`, + `merino_top_picks`, + `merino_wikipedia`, + `remote_tab`, + `rs_adm_nonsponsored`, + `rs_adm_sponsored`, + `search_engine`, + `search_history`, + `search_shortcut_button`, + `search_suggest`, + `search_suggest_rich`, + `site_specific_contextual_search`, + `suggest_non_sponsor`, + `suggest_sponsor`, + `tab`, + `tab_to_search`, + `tip_dismissal_acknowledgment`, + `tip_onboard`, + `tip_persist`, + `tip_redirect`, + `tip_unknown`, + `top_site`, + `trending_search`, + `trending_search_rich`, + `unit`, + `url`, + `weather` + type: string + selected_result_subtype: + description: > + The subtype of the result the user selected. Currently, only the + action of the quick actions is the target to this. The possible values + are: `addon`, `bookmarks`, `clear`, `downloads`, `extensions`, + `inspect`, `logins`, `plugins`, `print`, `private`, `refresh`, + `restart`, `screenshot`, `settings`, `themes`, `update` and + `viewsource`. Otherwise, an empty string is returned. + type: string + provider: + description: > + The name of the `UrlbarProvider` that provided the selected result. + The possible values are: `AboutPages`, `AliasEngines`, `Autofill`, + `BookmarkKeywords`, `calculator`, `UrlbarProviderContextualSearch`, + `HeuristicFallback`, `HistoryUrlHeuristic`, `InputHistory`, + `UrlbarProviderInterventions`, `Omnibox`, `OpenTabs`, `Places`, + `PreloadedSites`, `PrivateSearch`, `quickactions`, + `UrlbarProviderQuickSuggest`, `RemoteTabs`, `SearchSuggestions`, + `UrlbarProviderSearchTips`, `TabToSearch`, `TokenAliasEngines`, + `UrlbarProviderTopSites`, `UnitConversion` and `UnifiedComplete`. + If engagement_type is `drop_go` or `paste_go`, this will be null + because no results are shown. And also, if selected_result is + `experimental_addon`, it means that the user selected a result + from an add-on using the urlbar experimental API. In this case, + this will be the provider name specified by the add-on. + type: string + engagement_type: + description: > + Records how the user selected the result. The possible values are: + `click`, + `dismiss`, + `drop_go`, + `enter`, + `go_button`, + `help`, + `inaccurate_location`, + `not_interested`, + `not_relevant`, + `paste_go`, + `show_less_frequently` + type: string + groups: + description: > + Comma separated list of result groups in the order they were shown to + the user. The groups may be repeated, since the list will match 1:1 + the results list, so we can link each result to a group. The possible + group names are: `heuristic`, `adaptive_history`, `search_history`, + `search_suggest`, `search_suggest_rich`, `trending_search`, + `trending_search_rich`, `top_pick`, `top_site`, `remote_tab`, + `addon`, `general`, `suggest`, `about_page` and `suggested_index`. + If the group did not fall into any of these, this will be `unknown` + and a bug should be filed to investigate it. If engagement_type is + `drop_go` or `paste_go`, this will be empty string because no results + are shown. + type: string + results: + description: > + Comma separated list of result types in the order they were shown to + the user. The `unknown` type should not occur and indicates a bug. The + possible types are: + `action`, + `addon`, + `autofill_about`, + `autofill_adaptive`, + `autofill_origin`, + `autofill_unknown`, + `autofill_url`, + `bookmark`, + `calc`, + `history`, + `intervention_clear`, + `intervention_refresh`, + `intervention_unknown`, + `intervention_update`, + `keyword`, + `merino_adm_nonsponsored`, + `merino_adm_sponsored`, + `merino_amo`, + `merino_top_picks`, + `merino_wikipedia`, + `remote_tab`, + `rs_adm_nonsponsored`, + `rs_adm_sponsored`, + `search_engine`, + `search_history`, + `search_suggest`, + `search_suggest_rich`, + `suggest_non_sponsor`, + `suggest_sponsor`, + `tab`, + `tab_to_search`, + `tip_dismissal_acknowledgment`, + `tip_onboard`, + `tip_persist`, + `tip_redirect`, + `tip_unknown`, + `top_site`, + `trending_search`, + `trending_search_rich`, + `unit`, + `url`, + `weather` + type: string + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1797265 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1797265#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717#c4 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + impression: + disabled: true + type: event + description: Recorded when urlbar results are shown to the user. + extra_keys: + reason: + description: Reason for the impression. + type: string + sap: + description: > + `sap` is the meaning of `search access point`. It records where the + user started the search action from. The possible values are: `urlbar` + , `handoff`, `urlbar_newtab` and `urlbar_addonpage`. + type: string + interaction: + description: > + How the user started the search action. The possible values are: + `typed`, `pasted`, `topsite_search` (clicked on a topsite search + shortcut), `topsites` (selected a topsite result with empty search + string), `returned` (The user abandoned a search, then returned to it) + , `restarted` (The user abandoned a search, then returned to it, + cleared it and typed a completely different string), `refined` (The + user abandoned a search, then returned to it, and partially modified + the string), `persisted_search_terms` (The user returned to a previous + successful search that persisted terms in the urlbar), + `persisted_search_terms_restarted` (The user returned to a previous + successful search that persisted terms in the urlbar, then cleared it + and typed a completely different string) and + `persisted_search_terms_refined` (The user returned to a previous + successful search that persisted terms in the urlbar, and partially + modified the string). + type: string + search_mode: + description: > + If the urlbar is in search mode, thus restricting results to a + specific search engine or local source, this is set to the search mode + source. The possible sources are: `actions`, `bookmarks`, `history`, + `search_engine`, and `tabs`. If search mode is active but the source + did not fall into any of these categories, this will be `unknown`. If + search mode is not active, this will be an empty string. + type: string + n_chars: + description: > + The length of string used for the search. It includes whitespaces. + type: quantity + n_words: + description: > + The length of words used for the search. The words are made by + splitting the search string by whitespaces, thus this doesn’t support + CJK languages. For performance reasons a maximum of 255 characters are + considered when splitting. + type: quantity + n_results: + description: > + The number of results shown to the user. If this is high the results + list below may be truncated due to technical limitations. Also note in + that case not all the results may be physically visible due to the + screen size limitation. + type: quantity + groups: + description: > + Comma separated list of result groups in the order they were shown to + the user. The groups may be repeated, since the list will match 1:1 + the results list, so we can link each result to a group. The possible + group names are: `heuristic`, `adaptive_history`, `search_history`, + `search_suggest`, `search_suggest_rich`, `trending_search`, + `trending_search_rich`, `top_pick`, `top_site`, `remote_tab`, + `addon`, `general`, `suggest`, `about_page` and `suggested_index`. If + the group did not fall into any of these, this will be `unknown` and + a bug should be filed to investigate it. + type: string + results: + description: > + Comma separated list of result types in the order they were shown to + the user. The `unknown` type should not occur and indicates a bug. The + possible types are: + `action`, + `addon`, + `autofill_about`, + `autofill_adaptive`, + `autofill_origin`, + `autofill_unknown`, + `autofill_url`, + `bookmark`, + `calc`, + `history`, + `intervention_clear`, + `intervention_refresh`, + `intervention_unknown`, + `intervention_update`, + `keyword`, + `merino_adm_nonsponsored`, + `merino_adm_sponsored`, + `merino_amo`, + `merino_top_picks`, + `merino_wikipedia`, + `remote_tab`, + `rs_adm_nonsponsored`, + `rs_adm_sponsored`, + `search_engine`, + `search_history`, + `search_suggest`, + `search_suggest_rich`, + `suggest_non_sponsor`, + `suggest_sponsor`, + `tab`, + `tab_to_search`, + `tip_dismissal_acknowledgment`, + `tip_onboard`, + `tip_persist`, + `tip_redirect`, + `tip_unknown`, + `top_site`, + `trending_search`, + `trending_search_rich`, + `unit`, + `url`, + `weather` + type: string + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800579 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1800579#c4 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1805717#c4 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + + exposure: + type: event + description: > + Recorded when client is exposed to urlbar experiment results. + extra_keys: + results: + description: > + Comma separated list of results that were visible to the user. + type: string + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1819766 + data_reviews: + - "" + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + + pref_max_results: + lifetime: application + type: quantity + unit: integer + description: > + Maximum results to show in the Address Bar. + Corresponds to the value of the `browser.urlbar.maxRichResults` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817196 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817196 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + send_in_pings: + - events + + pref_suggest_topsites: + lifetime: application + type: boolean + description: > + Whether topsite results are enabled in the urlbar. + Corresponds to the value of the `browser.urlbar.suggest.topsites` pref. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817196 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817196 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + send_in_pings: + - events diff --git a/browser/components/urlbar/moz.build b/browser/components/urlbar/moz.build new file mode 100644 index 0000000000..b1d4c3e673 --- /dev/null +++ b/browser/components/urlbar/moz.build @@ -0,0 +1,87 @@ +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Address Bar") + +JAR_MANIFESTS += ["jar.mn"] + +DIRS += [ + "unitconverters", +] + +EXTRA_JS_MODULES += [ + "MerinoClient.sys.mjs", + "QuickActionsLoaderDefault.sys.mjs", + "QuickSuggest.sys.mjs", + "UrlbarController.sys.mjs", + "UrlbarEventBufferer.sys.mjs", + "UrlbarInput.sys.mjs", + "UrlbarMuxerUnifiedComplete.sys.mjs", + "UrlbarPrefs.sys.mjs", + "UrlbarProviderAboutPages.sys.mjs", + "UrlbarProviderAliasEngines.sys.mjs", + "UrlbarProviderAutofill.sys.mjs", + "UrlbarProviderBookmarkKeywords.sys.mjs", + "UrlbarProviderCalculator.sys.mjs", + "UrlbarProviderContextualSearch.sys.mjs", + "UrlbarProviderExtension.sys.mjs", + "UrlbarProviderHeuristicFallback.sys.mjs", + "UrlbarProviderHistoryUrlHeuristic.sys.mjs", + "UrlbarProviderInputHistory.sys.mjs", + "UrlbarProviderInterventions.sys.mjs", + "UrlbarProviderOmnibox.sys.mjs", + "UrlbarProviderOpenTabs.sys.mjs", + "UrlbarProviderPlaces.sys.mjs", + "UrlbarProviderPreloadedSites.sys.mjs", + "UrlbarProviderPrivateSearch.sys.mjs", + "UrlbarProviderQuickActions.sys.mjs", + "UrlbarProviderQuickSuggest.sys.mjs", + "UrlbarProviderRemoteTabs.sys.mjs", + "UrlbarProviderSearchSuggestions.sys.mjs", + "UrlbarProviderSearchTips.sys.mjs", + "UrlbarProvidersManager.sys.mjs", + "UrlbarProviderTabToSearch.sys.mjs", + "UrlbarProviderTokenAliasEngines.sys.mjs", + "UrlbarProviderTopSites.sys.mjs", + "UrlbarProviderUnitConversion.sys.mjs", + "UrlbarProviderWeather.sys.mjs", + "UrlbarResult.sys.mjs", + "UrlbarSearchOneOffs.sys.mjs", + "UrlbarSearchUtils.sys.mjs", + "UrlbarTokenizer.sys.mjs", + "UrlbarUtils.sys.mjs", + "UrlbarValueFormatter.sys.mjs", + "UrlbarView.sys.mjs", +] + +EXTRA_JS_MODULES["urlbar/private"] += [ + "private/AddonSuggestions.sys.mjs", + "private/AdmWikipedia.sys.mjs", + "private/BaseFeature.sys.mjs", + "private/BlockedSuggestions.sys.mjs", + "private/ImpressionCaps.sys.mjs", + "private/QuickSuggestRemoteSettings.sys.mjs", + "private/Weather.sys.mjs", +] + +TESTING_JS_MODULES += [ + "tests/quicksuggest/MerinoTestUtils.sys.mjs", + "tests/quicksuggest/QuickSuggestTestUtils.sys.mjs", + "tests/UrlbarTestUtils.sys.mjs", +] +BROWSER_CHROME_MANIFESTS += [ + "tests/browser-tips/browser.ini", + "tests/browser-updateResults/browser.ini", + "tests/browser/browser.ini", + "tests/engagementTelemetry/browser/browser.ini", + "tests/ext/browser/browser.ini", + "tests/quicksuggest/browser/browser.ini", +] +XPCSHELL_TESTS_MANIFESTS += [ + "tests/quicksuggest/unit/xpcshell.ini", + "tests/unit/xpcshell.ini", +] + +SPHINX_TREES["/browser/urlbar"] = "docs" diff --git a/browser/components/urlbar/private/AddonSuggestions.sys.mjs b/browser/components/urlbar/private/AddonSuggestions.sys.mjs new file mode 100644 index 0000000000..7232ad99f8 --- /dev/null +++ b/browser/components/urlbar/private/AddonSuggestions.sys.mjs @@ -0,0 +1,424 @@ +/* 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, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + SuggestionsMap: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.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 VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "content", + tag: "span", + overflowable: true, + children: [ + { + name: "icon", + tag: "img", + }, + { + name: "header", + tag: "span", + children: [ + { + name: "title", + tag: "span", + classList: ["urlbarView-title"], + }, + { + name: "separator", + tag: "span", + classList: ["urlbarView-title-separator"], + }, + { + name: "url", + tag: "span", + classList: ["urlbarView-url"], + }, + ], + }, + { + name: "description", + tag: "span", + }, + { + name: "footer", + tag: "span", + children: [ + { + name: "ratingContainer", + tag: "span", + children: [ + { + classList: ["urlbarView-dynamic-addons-rating"], + name: "rating0", + tag: "span", + }, + { + classList: ["urlbarView-dynamic-addons-rating"], + name: "rating1", + tag: "span", + }, + { + classList: ["urlbarView-dynamic-addons-rating"], + name: "rating2", + tag: "span", + }, + { + classList: ["urlbarView-dynamic-addons-rating"], + name: "rating3", + tag: "span", + }, + { + classList: ["urlbarView-dynamic-addons-rating"], + name: "rating4", + tag: "span", + }, + ], + }, + { + name: "reviews", + tag: "span", + }, + ], + }, + ], + }, + ], +}; + +const RESULT_MENU_COMMAND = { + HELP: "help", + NOT_INTERESTED: "not_interested", + NOT_RELEVANT: "not_relevant", + SHOW_LESS_FREQUENTLY: "show_less_frequently", +}; + +/** + * A feature that supports Addon suggestions. + */ +export class AddonSuggestions extends BaseFeature { + constructor() { + super(); + lazy.UrlbarResult.addDynamicResultType("addons"); + lazy.UrlbarView.addDynamicViewTemplate("addons", VIEW_TEMPLATE); + } + + get shouldEnable() { + return ( + lazy.UrlbarPrefs.get("addonsFeatureGate") && + lazy.UrlbarPrefs.get("suggest.addons") && + lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") + ); + } + + get enablingPreferences() { + return ["suggest.addons", "suggest.quicksuggest.nonsponsored"]; + } + + enable(enabled) { + if (enabled) { + lazy.QuickSuggestRemoteSettings.register(this); + } else { + lazy.QuickSuggestRemoteSettings.unregister(this); + } + } + + queryRemoteSettings(searchString) { + const suggestions = this.#suggestionsMap?.get(searchString); + if (!suggestions) { + return []; + } + + return suggestions.map(suggestion => ({ + icon: suggestion.icon, + url: suggestion.url, + title: suggestion.title, + description: suggestion.description, + rating: suggestion.rating, + number_of_ratings: suggestion.number_of_ratings, + guid: suggestion.guid, + score: suggestion.score, + is_top_pick: suggestion.is_top_pick, + })); + } + + async onRemoteSettingsSync(rs) { + const records = await rs.get({ filters: { type: "amo-suggestions" } }); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + const suggestionsMap = new lazy.SuggestionsMap(); + + for (const record of records) { + const { buffer } = await rs.attachments.download(record); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + const results = JSON.parse(new TextDecoder("utf-8").decode(buffer)); + + // The keywords in remote settings are full keywords. Map each one to an + // array containing the full keyword's first word plus every subsequent + // prefix of the full keyword. + await suggestionsMap.add(results, fullKeyword => { + let keywords = [fullKeyword]; + let spaceIndex = fullKeyword.search(/\s/); + if (spaceIndex >= 0) { + for (let i = spaceIndex; i < fullKeyword.length; i++) { + keywords.push(fullKeyword.substring(0, i)); + } + } + return keywords; + }); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + } + + this.#suggestionsMap = suggestionsMap; + } + + async makeResult(queryContext, suggestion, searchString) { + if (!this.isEnabled) { + // The feature is disabled on the client, but Merino may still return + // addon suggestions anyway, and we filter them out here. + return null; + } + + // If the user hasn't clicked the "Show less frequently" command, the + // suggestion can be shown. Otherwise, the suggestion can be shown if the + // user typed more than one word with at least `showLessFrequentlyCount` + // characters after the first word, including spaces. + if (this.showLessFrequentlyCount) { + let spaceIndex = searchString.search(/\s/); + if ( + spaceIndex < 0 || + searchString.length - spaceIndex < this.showLessFrequentlyCount + ) { + return null; + } + } + + // If is_top_pick is not specified, handle it as top pick suggestion. + suggestion.is_top_pick = suggestion.is_top_pick ?? true; + + const { guid, rating, number_of_ratings } = + suggestion.source === "remote-settings" + ? suggestion + : suggestion.custom_details.amo; + + const addon = await lazy.AddonManager.getAddonByID(guid); + if (addon) { + // Addon suggested is already installed. + return null; + } + + const payload = { + source: suggestion.source, + icon: suggestion.icon, + url: suggestion.url, + title: suggestion.title, + description: suggestion.description, + rating: Number(rating), + reviews: Number(number_of_ratings), + helpUrl: lazy.QuickSuggest.HELP_URL, + shouldNavigate: true, + dynamicType: "addons", + telemetryType: "amo", + }; + + return Object.assign( + new lazy.UrlbarResult( + lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, + lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ), + { showFeedbackMenu: true } + ); + } + + getViewUpdate(result) { + const treatment = lazy.UrlbarPrefs.get("addonsUITreatment"); + const rating = result.payload.rating; + + return { + content: { + attributes: { treatment }, + }, + icon: { + attributes: { + src: result.payload.icon, + }, + }, + url: { + textContent: result.payload.url, + }, + title: { + textContent: result.payload.title, + }, + description: { + textContent: result.payload.description, + }, + rating0: { + attributes: { + fill: this.#getRatingStar(0, rating), + }, + }, + rating1: { + attributes: { + fill: this.#getRatingStar(1, rating), + }, + }, + rating2: { + attributes: { + fill: this.#getRatingStar(2, rating), + }, + }, + rating3: { + attributes: { + fill: this.#getRatingStar(3, rating), + }, + }, + rating4: { + attributes: { + fill: this.#getRatingStar(4, rating), + }, + }, + reviews: { + l10n: + treatment === "b" + ? { id: "firefox-suggest-addons-recommended" } + : { + id: "firefox-suggest-addons-reviews", + args: { + quantity: result.payload.reviews, + }, + }, + }, + }; + } + + getResultCommands(result) { + const commands = []; + + if (this.canShowLessFrequently) { + 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; + } + + handlePossibleCommand(queryContext, 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: + lazy.UrlbarPrefs.set("suggest.addons", false); + queryContext.view.acknowledgeDismissal(result); + break; + case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: + queryContext.view.acknowledgeFeedback(result); + this.incrementShowLessFrequentlyCount(); + break; + } + } + + #getRatingStar(nth, rating) { + // 0 <= x < 0.25 = empty + // 0.25 <= x < 0.75 = half + // 0.75 <= x <= 1 = full + // ... et cetera, until x <= 5. + const distanceToFull = rating - nth; + if (distanceToFull < 0.25) { + return "empty"; + } + if (distanceToFull < 0.75) { + return "half"; + } + return "full"; + } + + incrementShowLessFrequentlyCount() { + if (this.canShowLessFrequently) { + lazy.UrlbarPrefs.set( + "addons.showLessFrequentlyCount", + this.showLessFrequentlyCount + 1 + ); + } + } + + get showLessFrequentlyCount() { + const count = lazy.UrlbarPrefs.get("addons.showLessFrequentlyCount") || 0; + return Math.max(count, 0); + } + + get canShowLessFrequently() { + const cap = + lazy.UrlbarPrefs.get("addonsShowLessFrequentlyCap") || + lazy.QuickSuggestRemoteSettings.config.show_less_frequently_cap || + 0; + return !cap || this.showLessFrequentlyCount < cap; + } + + #suggestionsMap = null; +} diff --git a/browser/components/urlbar/private/AdmWikipedia.sys.mjs b/browser/components/urlbar/private/AdmWikipedia.sys.mjs new file mode 100644 index 0000000000..4393ae317f --- /dev/null +++ b/browser/components/urlbar/private/AdmWikipedia.sys.mjs @@ -0,0 +1,280 @@ +/* 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, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + SuggestionsMap: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]); + +/** + * A feature that manages sponsored adM and non-sponsored Wikpedia (sometimes + * called "expanded Wikipedia") suggestions in remote settings. + */ +export class AdmWikipedia extends BaseFeature { + constructor() { + super(); + this.#suggestionsMap = new lazy.SuggestionsMap(); + } + + get shouldEnable() { + return ( + lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled") && + (lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") || + lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) + ); + } + + get enablingPreferences() { + return [ + "suggest.quicksuggest.nonsponsored", + "suggest.quicksuggest.sponsored", + ]; + } + + enable(enabled) { + if (enabled) { + lazy.QuickSuggestRemoteSettings.register(this); + } else { + lazy.QuickSuggestRemoteSettings.unregister(this); + } + } + + async queryRemoteSettings(searchString) { + let suggestions = this.#suggestionsMap.get(searchString); + if (!suggestions) { + return []; + } + + // Start each icon fetch at the same time and wait for them all to finish. + let icons = await Promise.all( + suggestions.map(({ icon }) => this.#fetchIcon(icon)) + ); + + return suggestions.map(suggestion => ({ + full_keyword: this.#getFullKeyword(searchString, suggestion.keywords), + title: suggestion.title, + url: suggestion.url, + click_url: suggestion.click_url, + impression_url: suggestion.impression_url, + block_id: suggestion.id, + advertiser: suggestion.advertiser, + iab_category: suggestion.iab_category, + is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(suggestion.iab_category), + score: suggestion.score, + position: suggestion.position, + icon: icons.shift(), + })); + } + + async onRemoteSettingsSync(rs) { + let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType"); + this.logger.debug("Loading remote settings with type: " + dataType); + + let [data] = await Promise.all([ + rs.get({ filters: { type: dataType } }), + rs + .get({ filters: { type: "icon" } }) + .then(icons => + Promise.all(icons.map(i => rs.attachments.downloadToDisk(i))) + ), + ]); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + let suggestionsMap = new lazy.SuggestionsMap(); + + this.logger.debug(`Got data with ${data.length} records`); + for (let record of data) { + let { buffer } = await rs.attachments.download(record); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + let results = JSON.parse(new TextDecoder("utf-8").decode(buffer)); + this.logger.debug(`Adding ${results.length} results`); + await suggestionsMap.add(results); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + } + + this.#suggestionsMap = suggestionsMap; + } + + makeResult(queryContext, suggestion, searchString) { + // Replace the suggestion's template substrings, but first save the original + // URL before its timestamp template is replaced. + let originalUrl = suggestion.url; + lazy.QuickSuggest.replaceSuggestionTemplates(suggestion); + + let payload = { + originalUrl, + url: suggestion.url, + icon: suggestion.icon, + isSponsored: suggestion.is_sponsored, + source: suggestion.source, + telemetryType: suggestion.is_sponsored + ? "adm_sponsored" + : "adm_nonsponsored", + requestId: suggestion.request_id, + urlTimestampIndex: suggestion.urlTimestampIndex, + sponsoredImpressionUrl: suggestion.impression_url, + sponsoredClickUrl: suggestion.click_url, + sponsoredBlockId: suggestion.block_id, + sponsoredAdvertiser: suggestion.advertiser, + sponsoredIabCategory: suggestion.iab_category, + helpUrl: lazy.QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }; + + // Determine if the suggestion itself is a best match. + let isSuggestionBestMatch = false; + if (lazy.QuickSuggestRemoteSettings.config.best_match) { + let { best_match } = lazy.QuickSuggestRemoteSettings.config; + isSuggestionBestMatch = + best_match.min_search_string_length <= searchString.length && + !best_match.blocked_suggestion_ids.includes(suggestion.block_id); + } + + // Determine if the urlbar result should be a best match. + let isResultBestMatch = + isSuggestionBestMatch && + lazy.UrlbarPrefs.get("bestMatchEnabled") && + lazy.UrlbarPrefs.get("suggest.bestmatch"); + if (isResultBestMatch) { + // Show the result as a best match. Best match titles don't include the + // `full_keyword`, and the user's search string is highlighted. + payload.title = [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + // Show the result as a usual quick suggest. Include the `full_keyword` + // and highlight the parts that aren't in the search string. + payload.title = suggestion.title; + payload.qsSuggestion = [ + suggestion.full_keyword, + lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED, + ]; + } + payload.isBlockable = lazy.UrlbarPrefs.get( + isResultBestMatch + ? "bestMatchBlockingEnabled" + : "quickSuggestBlockingEnabled" + ); + + let result = new lazy.UrlbarResult( + lazy.UrlbarUtils.RESULT_TYPE.URL, + lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + + if (isResultBestMatch) { + result.isBestMatch = true; + result.suggestedIndex = 1; + } + + return result; + } + + /** + * Gets the "full keyword" (i.e., suggestion) for a query from a list of + * keywords. The suggestions data doesn't include full keywords, so we make + * our own based on the result's keyword phrases and a particular query. We + * use two heuristics: + * + * (1) Find the first keyword phrase that has more words than the query. Use + * its first `queryWords.length` words as the full keyword. e.g., if the + * query is "moz" and `keywords` is ["moz", "mozi", "mozil", "mozill", + * "mozilla", "mozilla firefox"], pick "mozilla firefox", pop off the + * "firefox" and use "mozilla" as the full keyword. + * (2) If there isn't any keyword phrase with more words, then pick the + * longest phrase. e.g., pick "mozilla" in the previous example (assuming + * the "mozilla firefox" phrase isn't there). That might be the query + * itself. + * + * @param {string} query + * The query string. + * @param {Array} keywords + * An array of suggestion keywords. + * @returns {string} + * The full keyword. + */ + #getFullKeyword(query, keywords) { + let longerPhrase; + let trimmedQuery = query.toLocaleLowerCase().trim(); + let queryWords = trimmedQuery.split(" "); + + for (let phrase of keywords) { + if (phrase.startsWith(query)) { + let trimmedPhrase = phrase.trim(); + let phraseWords = trimmedPhrase.split(" "); + // As an exception to (1), if the query ends with a space, then look for + // phrases with one more word so that the suggestion includes a word + // following the space. + let extra = query.endsWith(" ") ? 1 : 0; + let len = queryWords.length + extra; + if (len < phraseWords.length) { + // We found a phrase with more words. + return phraseWords.slice(0, len).join(" "); + } + if ( + query.length < phrase.length && + (!longerPhrase || longerPhrase.length < trimmedPhrase.length) + ) { + // We found a longer phrase with the same number of words. + longerPhrase = trimmedPhrase; + } + } + } + return longerPhrase || trimmedQuery; + } + + /** + * Fetch the icon from RemoteSettings attachments. + * + * @param {string} path + * The icon's remote settings path. + */ + async #fetchIcon(path) { + if (!path) { + return null; + } + + let { rs } = lazy.QuickSuggestRemoteSettings; + if (!rs) { + return null; + } + + let record = ( + await rs.get({ + filters: { id: `icon-${path}` }, + }) + ).pop(); + if (!record) { + return null; + } + return rs.attachments.downloadToDisk(record); + } + + #suggestionsMap; +} diff --git a/browser/components/urlbar/private/BaseFeature.sys.mjs b/browser/components/urlbar/private/BaseFeature.sys.mjs new file mode 100644 index 0000000000..4893ffdf1c --- /dev/null +++ b/browser/components/urlbar/private/BaseFeature.sys.mjs @@ -0,0 +1,168 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +/** + * Base class for quick suggest features. It can be extended to implement a + * feature that is part of the larger quick suggest feature and that should be + * enabled only when quick suggest is enabled. + * + * You can extend this class as an alternative to implementing your feature + * directly in `QuickSuggest`. Doing so has the following advantages: + * + * - If your feature is gated on a Nimbus variable or preference, `QuickSuggest` + * will manage its lifetime automatically. This is really only useful if the + * feature has state that must be initialized when the feature is enabled and + * uninitialized when it's disabled. + * + * - Encapsulation. You can keep all the code related to your feature in one + * place, without mixing it with unrelated code and cluttering up + * `QuickSuggest`. You can also test it in isolation from `QuickSuggest`. + * + * - Remote settings management. You can register your feature with + * `QuickSuggestRemoteSettings` and it will be called at appropriate times to + * sync from remote settings. + * + * - If your feature also serves suggestions from remote settings, you can + * implement one method, `queryRemoteSettings()`, to hook into + * `UrlbarProviderQuickSuggest`. + * + * - Your feature will automatically get its own logger. + * + * To register your subclass with `QuickSuggest`, add it to the `FEATURES` const + * in QuickSuggest.sys.mjs. + */ +export class BaseFeature { + /** + * {boolean} + * Whether the feature should be enabled. Typically the subclass will check + * the values of one or more Nimbus variables or preferences. `QuickSuggest` + * will access this getter only when the quick suggest feature as a whole is + * enabled. Otherwise the subclass feature will be disabled automatically. + */ + get shouldEnable() { + throw new Error("`shouldEnable` must be overridden"); + } + + /** + * @returns {Array} + * If the subclass's `shouldEnable` implementation depends on preferences + * instead of Nimbus variables, the subclass should override this getter and + * return their names in this array so that `enable()` can be called when + * they change. Names should be in the same format that `UrlbarPrefs.get()` + * expects. + */ + get enablingPreferences() { + return null; + } + + /** + * This method should initialize or uninitialize any state related to the + * feature. + * + * @param {boolean} enabled + * Whether the feature should be enabled or not. + */ + enable(enabled) {} + + /** + * If the feature manages suggestions from remote settings that should be + * returned by UrlbarProviderQuickSuggest, the subclass should override this + * method. It should return remote settings suggestions matching the given + * search string. + * + * @param {string} searchString + * The search string. + * @returns {Array} + * An array of matching suggestions, or null if not implemented. + */ + async queryRemoteSettings(searchString) { + return null; + } + + /** + * If the feature manages data in remote settings, the subclass should + * override this method. It should fetch the data and build whatever data + * structures are necessary to support the feature. + * + * @param {RemoteSettings} rs + * The `RemoteSettings` client object. + */ + async onRemoteSettingsSync(rs) {} + + /** + * If the feature corresponds to a type of suggestion, the subclass should + * override this method. It should return a new `UrlbarResult` for a given + * suggestion, which can come from either remote settings or Merino. + * + * @param {UrlbarQueryContext} queryContext + * The query context. + * @param {object} suggestion + * The suggestion from either remote settings or Merino. + * @param {string} searchString + * The search string that was used to fetch the suggestion. It may be + * different from `queryContext.searchString` due to trimming, lower-casing, + * etc. This is included as a param in case it's useful. + * @returns {UrlbarResult} + * A new result for the suggestion. + */ + async makeResult(queryContext, suggestion, searchString) { + return null; + } + + // Methods not designed for overriding below + + /** + * @returns {Logger} + * The feature's logger. + */ + get logger() { + if (!this._logger) { + this._logger = lazy.UrlbarUtils.getLogger({ + prefix: `QuickSuggest.${this.name}`, + }); + } + return this._logger; + } + + /** + * @returns {boolean} + * Whether the feature is enabled. The enabled status is automatically + * managed by `QuickSuggest` and subclasses should not override this. + */ + get isEnabled() { + return this.#isEnabled; + } + + /** + * @returns {string} + * The feature's name. + */ + get name() { + return this.constructor.name; + } + + /** + * Enables or disables the feature according to `shouldEnable` and whether + * quick suggest is enabled. If the feature is already enabled appropriately, + * does nothing. + */ + update() { + let enable = + lazy.UrlbarPrefs.get("quickSuggestEnabled") && this.shouldEnable; + if (enable != this.isEnabled) { + this.logger.info(`Setting enabled = ${enable}`); + this.enable(enable); + this.#isEnabled = enable; + } + } + + #isEnabled = false; +} diff --git a/browser/components/urlbar/private/BlockedSuggestions.sys.mjs b/browser/components/urlbar/private/BlockedSuggestions.sys.mjs new file mode 100644 index 0000000000..d74a0979d1 --- /dev/null +++ b/browser/components/urlbar/private/BlockedSuggestions.sys.mjs @@ -0,0 +1,187 @@ +/* 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, { + TaskQueue: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", +}); + +/** + * A set of blocked suggestions for quick suggest. + */ +export class BlockedSuggestions extends BaseFeature { + constructor() { + super(); + this.#taskQueue = new lazy.TaskQueue(); + lazy.UrlbarPrefs.addObserver(this); + } + + get shouldEnable() { + // Return true so that we'll always load blocked digests when quick suggest + // is enabled, even if blocking new suggestions is currently disabled. + // Blocking may have been enabled previously, and blocked suggestions should + // remain blocked as long as quick suggest as a whole remains enabled. + return true; + } + + enable(enabled) { + if (enabled) { + this.#loadDigests(); + } + } + + /** + * Blocks a suggestion. + * + * @param {string} originalUrl + * The suggestion's original URL with its unreplaced timestamp template. + */ + async add(originalUrl) { + this.logger.debug(`Queueing add: ${originalUrl}`); + await this.#taskQueue.queue(async () => { + this.logger.info(`Blocking suggestion: ${originalUrl}`); + let digest = await this.#getDigest(originalUrl); + this.logger.debug(`Got digest for '${originalUrl}': ${digest}`); + this.#digests.add(digest); + let json = JSON.stringify([...this.#digests]); + this.#updatingDigests = true; + try { + lazy.UrlbarPrefs.set("quicksuggest.blockedDigests", json); + } finally { + this.#updatingDigests = false; + } + this.logger.debug(`All blocked suggestions: ${json}`); + }); + } + + /** + * Gets whether a suggestion is blocked. + * + * @param {string} originalUrl + * The suggestion's original URL with its unreplaced timestamp template. + * @returns {boolean} + * Whether the suggestion is blocked. + */ + async has(originalUrl) { + this.logger.debug(`Queueing has: ${originalUrl}`); + return this.#taskQueue.queue(async () => { + this.logger.info(`Getting blocked status: ${originalUrl}`); + let digest = await this.#getDigest(originalUrl); + this.logger.debug(`Got digest for '${originalUrl}': ${digest}`); + let isBlocked = this.#digests.has(digest); + this.logger.info(`Blocked status for '${originalUrl}': ${isBlocked}`); + return isBlocked; + }); + } + + /** + * Unblocks all suggestions. + */ + async clear() { + this.logger.debug(`Queueing clearBlockedSuggestions`); + await this.#taskQueue.queue(() => { + this.logger.info(`Clearing all blocked suggestions`); + this.#digests.clear(); + lazy.UrlbarPrefs.clear("quicksuggest.blockedDigests"); + }); + } + + /** + * Called when a urlbar pref changes. + * + * @param {string} pref + * The name of the pref relative to `browser.urlbar`. + */ + onPrefChanged(pref) { + switch (pref) { + case "quicksuggest.blockedDigests": + if (!this.#updatingDigests) { + this.logger.info( + "browser.urlbar.quicksuggest.blockedDigests changed" + ); + this.#loadDigests(); + } + break; + } + } + + /** + * Loads blocked suggestion digests from the pref into `#digests`. + */ + async #loadDigests() { + this.logger.debug(`Queueing #loadDigests`); + await this.#taskQueue.queue(() => { + this.logger.info(`Loading blocked suggestion digests`); + let json = lazy.UrlbarPrefs.get("quicksuggest.blockedDigests"); + this.logger.debug( + `browser.urlbar.quicksuggest.blockedDigests value: ${json}` + ); + if (!json) { + this.logger.info(`There are no blocked suggestion digests`); + this.#digests.clear(); + } else { + try { + this.#digests = new Set(JSON.parse(json)); + this.logger.info(`Successfully loaded blocked suggestion digests`); + } catch (error) { + this.logger.error( + `Error loading blocked suggestion digests: ${error}` + ); + } + } + }); + } + + /** + * Returns the SHA-1 digest of a string as a 40-character hex-encoded string. + * + * @param {string} string + * The string to convert to SHA-1 + * @returns {string} + * The hex-encoded digest of the given string. + */ + async #getDigest(string) { + let stringArray = new TextEncoder().encode(string); + let hashBuffer = await crypto.subtle.digest("SHA-1", stringArray); + let hashArray = new Uint8Array(hashBuffer); + return Array.from(hashArray, b => b.toString(16).padStart(2, "0")).join(""); + } + + get _test_readyPromise() { + return this.#taskQueue.emptyPromise; + } + + get _test_digests() { + return this.#digests; + } + + _test_getDigest(string) { + return this.#getDigest(string); + } + + // Set of digests of the original URLs of blocked suggestions. A suggestion's + // "original URL" is its URL straight from the source with an unreplaced + // timestamp template. For details on the digests, see `#getDigest()`. + // + // The only reason we use URL digests is that suggestions currently do not + // have persistent IDs. We could use the URLs themselves but SHA-1 digests are + // only 40 chars long, so they save a little space. This is also consistent + // with how blocked tiles on the newtab page are stored, but they use MD5. We + // do *not* store digests for any security or obfuscation reason. + // + // This value is serialized as a JSON'ed array to the + // `browser.urlbar.quicksuggest.blockedDigests` pref. + #digests = new Set(); + + // Used to serialize access to blocked suggestions. This is only necessary + // because getting a suggestion's URL digest is async. + #taskQueue = null; + + // Whether blocked digests are currently being updated. + #updatingDigests = false; +} diff --git a/browser/components/urlbar/private/ImpressionCaps.sys.mjs b/browser/components/urlbar/private/ImpressionCaps.sys.mjs new file mode 100644 index 0000000000..b50d87bf9e --- /dev/null +++ b/browser/components/urlbar/private/ImpressionCaps.sys.mjs @@ -0,0 +1,566 @@ +/* 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, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + clearInterval: "resource://gre/modules/Timer.sys.mjs", + setInterval: "resource://gre/modules/Timer.sys.mjs", +}); + +const IMPRESSION_COUNTERS_RESET_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +// This object maps impression stats object keys to their corresponding keys in +// the `extra` object of impression cap telemetry events. The main reason this +// is necessary is because the keys of the `extra` object are limited to 15 +// characters in length, which some stats object keys exceed. It also forces us +// to be deliberate about keys we add to the `extra` object, since the `extra` +// object is limited to 10 keys. +const TELEMETRY_IMPRESSION_CAP_EXTRA_KEYS = { + // stats object key -> `extra` telemetry event object key + intervalSeconds: "intervalSeconds", + startDateMs: "startDate", + count: "count", + maxCount: "maxCount", + impressionDateMs: "impressionDate", +}; + +/** + * Impression caps and stats for quick suggest suggestions. + */ +export class ImpressionCaps extends BaseFeature { + constructor() { + super(); + lazy.UrlbarPrefs.addObserver(this); + } + + get shouldEnable() { + return ( + lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled") || + lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled") + ); + } + + enable(enabled) { + if (enabled) { + this.#init(); + } else { + this.#uninit(); + } + } + + /** + * Increments the user's impression stats counters for the given type of + * suggestion. This should be called only when a suggestion impression is + * recorded. + * + * @param {string} type + * The suggestion type, one of: "sponsored", "nonsponsored" + */ + updateStats(type) { + this.logger.info("Starting impression stats update"); + this.logger.debug( + JSON.stringify({ + type, + currentStats: this.#stats, + impression_caps: lazy.QuickSuggestRemoteSettings.config.impression_caps, + }) + ); + + // Don't bother recording anything if caps are disabled. + let isSponsored = type == "sponsored"; + if ( + (isSponsored && + !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsSponsoredEnabled")) || + (!isSponsored && + !lazy.UrlbarPrefs.get("quickSuggestImpressionCapsNonSponsoredEnabled")) + ) { + this.logger.info("Impression caps disabled, skipping update"); + return; + } + + // Get the user's impression stats. Since stats are synced from caps, if the + // stats don't exist then the caps don't exist, and don't bother recording + // anything in that case. + let stats = this.#stats[type]; + if (!stats) { + this.logger.info("Impression caps undefined, skipping update"); + return; + } + + // Increment counters. + for (let stat of stats) { + stat.count++; + stat.impressionDateMs = Date.now(); + + // Record a telemetry event for each newly hit cap. + if (stat.count == stat.maxCount) { + this.logger.info(`'${type}' impression cap hit`); + this.logger.debug(JSON.stringify({ type, hitStat: stat })); + this.#recordCapEvent({ + stat, + eventType: "hit", + suggestionType: type, + }); + } + } + + // Save the stats. + this.#updatingStats = true; + try { + lazy.UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify(this.#stats) + ); + } finally { + this.#updatingStats = false; + } + + this.logger.info("Finished impression stats update"); + this.logger.debug(JSON.stringify({ newStats: this.#stats })); + } + + /** + * Returns a non-null value if an impression cap has been reached for the + * given suggestion type and null otherwise. This method can therefore be used + * to tell whether a cap has been reached for a given type. The actual return + * value an object describing the impression stats that caused the cap to be + * reached. + * + * @param {string} type + * The suggestion type, one of: "sponsored", "nonsponsored" + * @returns {object} + * An impression stats object or null. + */ + getHitStats(type) { + this.#resetElapsedCounters(); + let stats = this.#stats[type]; + if (stats) { + let hitStats = stats.filter(s => s.maxCount <= s.count); + if (hitStats.length) { + return hitStats; + } + } + return null; + } + + /** + * Called when a urlbar pref changes. + * + * @param {string} pref + * The name of the pref relative to `browser.urlbar`. + */ + onPrefChanged(pref) { + switch (pref) { + case "quicksuggest.impressionCaps.stats": + if (!this.#updatingStats) { + this.logger.info( + "browser.urlbar.quicksuggest.impressionCaps.stats changed" + ); + this.#loadStats(); + } + break; + } + } + + #init() { + this.#loadStats(); + + // Validate stats against any changes to the impression caps in the config. + this._onConfigSet = () => this.#validateStats(); + lazy.QuickSuggestRemoteSettings.emitter.on("config-set", this._onConfigSet); + + // Periodically record impression counters reset telemetry. + this.#setCountersResetInterval(); + + // On shutdown, record any final impression counters reset telemetry. + this._shutdownBlocker = () => this.#resetElapsedCounters(); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + "QuickSuggest: Record impression counters reset telemetry", + this._shutdownBlocker + ); + } + + #uninit() { + lazy.QuickSuggestRemoteSettings.emitter.off( + "config-set", + this._onConfigSet + ); + this._onConfigSet = null; + + lazy.clearInterval(this._impressionCountersResetInterval); + this._impressionCountersResetInterval = 0; + + lazy.AsyncShutdown.profileChangeTeardown.removeBlocker( + this._shutdownBlocker + ); + this._shutdownBlocker = null; + } + + /** + * Loads and validates impression stats. + */ + #loadStats() { + let json = lazy.UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); + if (!json) { + this.#stats = {}; + } else { + try { + this.#stats = JSON.parse( + json, + // Infinity, which is the `intervalSeconds` for the lifetime cap, is + // stringified as `null` in the JSON, so convert it back to Infinity. + (key, value) => + key == "intervalSeconds" && value === null ? Infinity : value + ); + } catch (error) {} + } + this.#validateStats(); + } + + /** + * Validates impression stats, which includes two things: + * + * - Type checks stats and discards any that are invalid. We do this because + * stats are stored in prefs where anyone can modify them. + * - Syncs stats with impression caps so that there is one stats object + * corresponding to each impression cap. See the `#stats` comment for info. + */ + #validateStats() { + let { impression_caps } = lazy.QuickSuggestRemoteSettings.config; + + this.logger.info("Validating impression stats"); + this.logger.debug( + JSON.stringify({ + impression_caps, + currentStats: this.#stats, + }) + ); + + if (!this.#stats || typeof this.#stats != "object") { + this.#stats = {}; + } + + for (let [type, cap] of Object.entries(impression_caps || {})) { + // Build a map from interval seconds to max counts in the caps. + let maxCapCounts = (cap.custom || []).reduce( + (map, { interval_s, max_count }) => { + map.set(interval_s, max_count); + return map; + }, + new Map() + ); + if (typeof cap.lifetime == "number") { + maxCapCounts.set(Infinity, cap.lifetime); + } + + let stats = this.#stats[type]; + if (!Array.isArray(stats)) { + stats = []; + this.#stats[type] = stats; + } + + // Validate existing stats: + // + // * Discard stats with invalid properties. + // * Collect and remove stats with intervals that aren't in the caps. This + // should only happen when caps are changed or removed. + // * For stats with intervals that are in the caps: + // * Keep track of the max `stat.count` across all stats so we can + // update the lifetime stat below. + // * Set `stat.maxCount` to the max count in the corresponding cap. + let orphanStats = []; + let maxCountInStats = 0; + for (let i = 0; i < stats.length; ) { + let stat = stats[i]; + if ( + typeof stat.intervalSeconds != "number" || + typeof stat.startDateMs != "number" || + typeof stat.count != "number" || + typeof stat.maxCount != "number" || + typeof stat.impressionDateMs != "number" + ) { + stats.splice(i, 1); + } else { + maxCountInStats = Math.max(maxCountInStats, stat.count); + let maxCount = maxCapCounts.get(stat.intervalSeconds); + if (maxCount === undefined) { + stats.splice(i, 1); + orphanStats.push(stat); + } else { + stat.maxCount = maxCount; + i++; + } + } + } + + // Create stats for caps that don't already have corresponding stats. + for (let [intervalSeconds, maxCount] of maxCapCounts.entries()) { + if (!stats.some(s => s.intervalSeconds == intervalSeconds)) { + stats.push({ + maxCount, + intervalSeconds, + startDateMs: Date.now(), + count: 0, + impressionDateMs: 0, + }); + } + } + + // Merge orphaned stats into other ones if possible. For each orphan, if + // its interval is no bigger than an existing stat's interval, then the + // orphan's count can contribute to the existing stat's count, so merge + // the two. + for (let orphan of orphanStats) { + for (let stat of stats) { + if (orphan.intervalSeconds <= stat.intervalSeconds) { + stat.count = Math.max(stat.count, orphan.count); + stat.startDateMs = Math.min(stat.startDateMs, orphan.startDateMs); + stat.impressionDateMs = Math.max( + stat.impressionDateMs, + orphan.impressionDateMs + ); + } + } + } + + // If the lifetime stat exists, make its count the max count found above. + // This is only necessary when the lifetime cap wasn't present before, but + // it doesn't hurt to always do it. + let lifetimeStat = stats.find(s => s.intervalSeconds == Infinity); + if (lifetimeStat) { + lifetimeStat.count = maxCountInStats; + } + + // Sort the stats by interval ascending. This isn't necessary except that + // it guarantees an ordering for tests. + stats.sort((a, b) => a.intervalSeconds - b.intervalSeconds); + } + + this.logger.debug(JSON.stringify({ newStats: this.#stats })); + } + + /** + * Resets the counters of impression stats whose intervals have elapased. + */ + #resetElapsedCounters() { + this.logger.info("Checking for elapsed impression cap intervals"); + this.logger.debug( + JSON.stringify({ + currentStats: this.#stats, + impression_caps: lazy.QuickSuggestRemoteSettings.config.impression_caps, + }) + ); + + let now = Date.now(); + for (let [type, stats] of Object.entries(this.#stats)) { + for (let stat of stats) { + let elapsedMs = now - stat.startDateMs; + let intervalMs = 1000 * stat.intervalSeconds; + let elapsedIntervalCount = Math.floor(elapsedMs / intervalMs); + if (elapsedIntervalCount) { + // At least one interval period elapsed for the stat, so reset it. We + // may also need to record a telemetry event for the reset. + this.logger.info( + `Resetting impression counter for interval ${stat.intervalSeconds}s` + ); + this.logger.debug( + JSON.stringify({ type, stat, elapsedMs, elapsedIntervalCount }) + ); + + let newStartDateMs = + stat.startDateMs + elapsedIntervalCount * intervalMs; + + // Compute the portion of `elapsedIntervalCount` that happened after + // startup. This will be the interval count we report in the telemetry + // event. By design we don't report intervals that elapsed while the + // app wasn't running. For example, if the user stopped using Firefox + // for a year, we don't want to report a year's worth of intervals. + // + // First, compute the count of intervals that elapsed before startup. + // This is the same arithmetic used above except here it's based on + // the startup date instead of `now`. Keep in mind that startup may be + // before the stat's start date. Then subtract that count from + // `elapsedIntervalCount` to get the portion after startup. + let startupDateMs = this._getStartupDateMs(); + let elapsedIntervalCountBeforeStartup = Math.floor( + Math.max(0, startupDateMs - stat.startDateMs) / intervalMs + ); + let elapsedIntervalCountAfterStartup = + elapsedIntervalCount - elapsedIntervalCountBeforeStartup; + + if (elapsedIntervalCountAfterStartup) { + this.#recordCapEvent({ + eventType: "reset", + suggestionType: type, + eventDateMs: newStartDateMs, + eventCount: elapsedIntervalCountAfterStartup, + stat: { + ...stat, + startDateMs: + stat.startDateMs + + elapsedIntervalCountBeforeStartup * intervalMs, + }, + }); + } + + // Reset the stat. + stat.startDateMs = newStartDateMs; + stat.count = 0; + } + } + } + + this.logger.debug(JSON.stringify({ newStats: this.#stats })); + } + + /** + * Records an impression cap telemetry event. + * + * @param {object} options + * Options object + * @param {"hit" | "reset"} options.eventType + * One of: "hit", "reset" + * @param {string} options.suggestionType + * One of: "sponsored", "nonsponsored" + * @param {object} options.stat + * The stats object whose max count was hit or whose counter was reset. + * @param {number} options.eventCount + * The number of intervals that elapsed since the last event. + * @param {number} options.eventDateMs + * The `eventDate` that should be recorded in the event's `extra` object. + * We include this in `extra` even though events are timestamped because + * "reset" events are batched during periods where the user doesn't perform + * any searches and therefore impression counters are not reset. + */ + #recordCapEvent({ + eventType, + suggestionType, + stat, + eventCount = 1, + eventDateMs = Date.now(), + }) { + // All `extra` object values must be strings. + let extra = { + type: suggestionType, + eventDate: String(eventDateMs), + eventCount: String(eventCount), + }; + for (let [statKey, value] of Object.entries(stat)) { + let extraKey = TELEMETRY_IMPRESSION_CAP_EXTRA_KEYS[statKey]; + if (!extraKey) { + throw new Error("Unrecognized stats object key: " + statKey); + } + extra[extraKey] = String(value); + } + Services.telemetry.recordEvent( + lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + "impression_cap", + eventType, + "", + extra + ); + } + + /** + * Creates a repeating timer that resets impression counters and records + * related telemetry. Since counters are also reset when suggestions are + * triggered, the only point of this is to make sure we record reset telemetry + * events in a timely manner during periods when suggestions aren't triggered. + * + * @param {number} ms + * The number of milliseconds in the interval. + */ + #setCountersResetInterval(ms = IMPRESSION_COUNTERS_RESET_INTERVAL_MS) { + if (this._impressionCountersResetInterval) { + lazy.clearInterval(this._impressionCountersResetInterval); + } + this._impressionCountersResetInterval = lazy.setInterval( + () => this.#resetElapsedCounters(), + ms + ); + } + + /** + * Gets the timestamp of app startup in ms since Unix epoch. This is only + * defined as its own method so tests can override it to simulate arbitrary + * startups. + * + * @returns {number} + * Startup timestamp in ms since Unix epoch. + */ + _getStartupDateMs() { + return Services.startup.getStartupInfo().process.getTime(); + } + + get _test_stats() { + return this.#stats; + } + + _test_reloadStats() { + this.#stats = null; + this.#loadStats(); + } + + _test_resetElapsedCounters() { + this.#resetElapsedCounters(); + } + + _test_setCountersResetInterval(ms) { + this.#setCountersResetInterval(ms); + } + + // An object that keeps track of impression stats per sponsored and + // non-sponsored suggestion types. It looks like this: + // + // { sponsored: statsArray, nonsponsored: statsArray } + // + // The `statsArray` values are arrays of stats objects, one per impression + // cap, which look like this: + // + // { intervalSeconds, startDateMs, count, maxCount, impressionDateMs } + // + // {number} intervalSeconds + // The number of seconds in the corresponding cap's time interval. + // {number} startDateMs + // The timestamp at which the current interval period started and the + // object's `count` was reset to zero. This is a value returned from + // `Date.now()`. When the current date/time advances past `startDateMs + + // 1000 * intervalSeconds`, a new interval period will start and `count` + // will be reset to zero. + // {number} count + // The number of impressions during the current interval period. + // {number} maxCount + // The maximum number of impressions allowed during an interval period. + // This value is the same as the `max_count` value in the corresponding + // cap. It's stored in the stats object for convenience. + // {number} impressionDateMs + // The timestamp of the most recent impression, i.e., when `count` was + // last incremented. + // + // There are two types of impression caps: interval and lifetime. Interval + // caps are periodically reset, and lifetime caps are never reset. For stats + // objects corresponding to interval caps, `intervalSeconds` will be the + // `interval_s` value of the cap. For stats objects corresponding to lifetime + // caps, `intervalSeconds` will be `Infinity`. + // + // `#stats` is kept in sync with impression caps, and there is a one-to-one + // relationship between stats objects and caps. A stats object's corresponding + // cap is the one with the same suggestion type (sponsored or non-sponsored) + // and interval. See `#validateStats()` for more. + // + // Impression caps are stored in the remote settings config. See + // `QuickSuggestRemoteSettingsClient.confg.impression_caps`. + #stats = {}; + + // Whether impression stats are currently being updated. + #updatingStats = false; +} diff --git a/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs new file mode 100644 index 0000000000..326b649780 --- /dev/null +++ b/browser/components/urlbar/private/QuickSuggestRemoteSettings.sys.mjs @@ -0,0 +1,395 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventEmitter: "resource://gre/modules/EventEmitter.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const RS_COLLECTION = "quicksuggest"; + +// Default score for remote settings suggestions. +const DEFAULT_SUGGESTION_SCORE = 0.2; + +// Entries are added to `SuggestionsMap` map in chunks, and each chunk will add +// at most this many entries. +const SUGGESTIONS_MAP_CHUNK_SIZE = 1000; + +const TELEMETRY_LATENCY = "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +/** + * Manages quick suggest remote settings data. + */ +class _QuickSuggestRemoteSettings { + /** + * @returns {number} + * The default score for remote settings suggestions, a value in the range + * [0, 1]. All suggestions require a score that can be used for comparison, + * so if a remote settings suggestion does not have one, it's assigned this + * value. + */ + get DEFAULT_SUGGESTION_SCORE() { + return DEFAULT_SUGGESTION_SCORE; + } + + constructor() { + this.#emitter = new lazy.EventEmitter(); + } + + /** + * @returns {RemoteSettings} + * The underlying `RemoteSettings` client object. + */ + get rs() { + return this.#rs; + } + + /** + * @returns {EventEmitter} + * The client will emit events on this object. + */ + get emitter() { + return this.#emitter; + } + + /** + * @returns {object} + * Global quick suggest configuration stored in remote settings. When the + * config changes the `emitter` property will emit a "config-set" event. The + * config is an object that looks like this: + * + * { + * best_match: { + * min_search_string_length, + * blocked_suggestion_ids, + * }, + * impression_caps: { + * nonsponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * sponsored: { + * lifetime, + * custom: [ + * { interval_s, max_count }, + * ], + * }, + * }, + * show_less_frequently_cap, + * } + */ + get config() { + return this.#config; + } + + /** + * @returns {Array} + * Array of `BasicFeature` instances. + */ + get features() { + return [...this.#features]; + } + + get logger() { + if (!this.#logger) { + this.#logger = lazy.UrlbarUtils.getLogger({ + prefix: "QuickSuggestRemoteSettings", + }); + } + return this.#logger; + } + + /** + * Registers a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. See `BaseFeature` for methods + * that the subclass must implement. + */ + register(feature) { + this.logger.debug("Registering feature: " + feature.name); + this.#features.add(feature); + if (this.#features.size == 1) { + this.#enableSettings(true); + } + this.#syncFeature(feature); + } + + /** + * Unregisters a quick suggest feature that uses remote settings. + * + * @param {BaseFeature} feature + * An instance of a `BaseFeature` subclass. + */ + unregister(feature) { + this.logger.debug("Unregistering feature: " + feature.name); + this.#features.delete(feature); + if (!this.#features.size) { + this.#enableSettings(false); + } + } + + /** + * Queries remote settings suggestions from all registered features. + * + * @param {string} searchString + * The search string. + * @returns {Array} + * The remote settings suggestions. If there are no matches, an empty array + * is returned. + */ + async query(searchString) { + let suggestions; + let stopwatchInstance = {}; + TelemetryStopwatch.start(TELEMETRY_LATENCY, stopwatchInstance); + try { + suggestions = await this.#queryHelper(searchString); + TelemetryStopwatch.finish(TELEMETRY_LATENCY, stopwatchInstance); + } catch (error) { + TelemetryStopwatch.cancel(TELEMETRY_LATENCY, stopwatchInstance); + this.logger.error("Query error: " + error); + } + + return suggestions || []; + } + + async #queryHelper(searchString) { + this.logger.info("Handling query: " + JSON.stringify(searchString)); + + let results = await Promise.all( + [...this.#features].map(async feature => { + let suggestions = await feature.queryRemoteSettings(searchString); + return [feature, suggestions ?? []]; + }) + ); + + let allSuggestions = []; + for (let [feature, suggestions] of results) { + for (let suggestion of suggestions) { + suggestion.source = "remote-settings"; + suggestion.provider = feature.name; + if (typeof suggestion.score != "number") { + suggestion.score = DEFAULT_SUGGESTION_SCORE; + } + allSuggestions.push(suggestion); + } + } + + return allSuggestions; + } + + async #enableSettings(enabled) { + if (enabled && !this.#rs) { + this.logger.debug("Creating RemoteSettings client"); + this.#onSettingsSync = event => this.#syncAll({ event }); + this.#rs = lazy.RemoteSettings(RS_COLLECTION); + this.#rs.on("sync", this.#onSettingsSync); + await this.#syncConfig(); + } else if (!enabled && this.#rs) { + this.logger.debug("Destroying RemoteSettings client"); + this.#rs.off("sync", this.#onSettingsSync); + this.#rs = null; + this.#onSettingsSync = null; + } + } + + async #syncConfig() { + this.logger.debug("Syncing config"); + let rs = this.#rs; + + let configArray = await rs.get({ filters: { type: "configuration" } }); + if (rs != this.#rs) { + return; + } + + this.logger.debug("Got config array: " + JSON.stringify(configArray)); + this.#setConfig(configArray?.[0]?.configuration || {}); + } + + async #syncFeature(feature) { + this.logger.debug("Syncing feature: " + feature.name); + await feature.onRemoteSettingsSync(this.#rs); + } + + async #syncAll({ event = null } = {}) { + this.logger.debug("Syncing all"); + let rs = this.#rs; + + // Remove local files of deleted records + if (event?.data?.deleted) { + await Promise.all( + event.data.deleted + .filter(d => d.attachment) + .map(entry => + Promise.all([ + this.#rs.attachments.deleteDownloaded(entry), // type: data + this.#rs.attachments.deleteFromDisk(entry), // type: icon + ]) + ) + ); + if (rs != this.#rs) { + return; + } + } + + let promises = [this.#syncConfig()]; + for (let feature of this.#features) { + promises.push(this.#syncFeature(feature)); + } + await Promise.all(promises); + } + + /** + * Sets the quick suggest config and emits a "config-set" event. + * + * @param {object} config + * The config object. + */ + #setConfig(config) { + config ??= {}; + this.logger.debug("Setting config: " + JSON.stringify(config)); + this.#config = config; + this.#emitter.emit("config-set"); + } + + // The `RemoteSettings` client. + #rs = null; + + // Registered `BaseFeature` instances. + #features = new Set(); + + // Configuration data synced from remote settings. See the `config` getter. + #config = {}; + + #emitter = null; + #logger = null; + #onSettingsSync = null; +} + +export var QuickSuggestRemoteSettings = new _QuickSuggestRemoteSettings(); + +/** + * A wrapper around `Map` that handles quick suggest suggestions from remote + * settings. It maps keywords to suggestions. It has two benefits over `Map`: + * + * - The main benefit is that map entries are added in batches on idle to avoid + * blocking the main thread for too long, since there can be many suggestions + * and keywords. + * - A secondary benefit is that the interface is tailored to quick suggest + * suggestions, which have a `keywords` property. + */ +export class SuggestionsMap { + /** + * Returns the list of suggestions for a keyword. + * + * @param {string} keyword + * The keyword. + * @returns {Array} + * The array of suggestions for the keyword. If the keyword isn't in the + * map, the array will be empty. + */ + get(keyword) { + let object = this.#suggestionsByKeyword.get(keyword.toLocaleLowerCase()); + if (!object) { + return []; + } + return Array.isArray(object) ? object : [object]; + } + + /** + * Adds a list of suggestion objects to the results map. Each suggestion must + * have a `keywords` property whose value is an array of keyword strings. The + * suggestion's keywords will be taken from this array either exactly as they + * are specified or by generating new keywords from them; see `mapKeyword`. + * + * @param {Array} suggestions + * Array of suggestion objects. + * @param {Function} mapKeyword + * If null, each suggestion's keywords will be taken from its `keywords` + * array exactly as they are specified. Otherwise, as each suggestion is + * processed, this function will be called for each string in its `keywords` + * array. The function should return an array of strings. The suggestion's + * final list of keywords will be all the keywords returned by this function + * as it is called for each string in `keywords`. + */ + async add(suggestions, mapKeyword = null) { + // There can be many suggestions, and each suggestion can have many + // keywords. To avoid blocking the main thread for too long, update the map + // in chunks, and to avoid blocking the UI and other higher priority work, + // do each chunk only when the main thread is idle. During each chunk, we'll + // add at most `chunkSize` entries to the map. + let suggestionIndex = 0; + let keywordIndex = 0; + + // Keep adding chunks until all suggestions have been fully added. + while (suggestionIndex < suggestions.length) { + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(() => { + // Keep updating the map until the current chunk is done. + let indexInChunk = 0; + while ( + indexInChunk < SuggestionsMap.chunkSize && + suggestionIndex < suggestions.length + ) { + let suggestion = suggestions[suggestionIndex]; + if (keywordIndex == suggestion.keywords.length) { + // We've added entries for all keywords of the current suggestion. + // Move on to the next suggestion. + suggestionIndex++; + keywordIndex = 0; + continue; + } + + let originalKeyword = suggestion.keywords[keywordIndex]; + let keywords = mapKeyword?.(originalKeyword) ?? [originalKeyword]; + for (let keyword of keywords) { + // If the keyword's only suggestion is `suggestion`, store it + // directly as the value. Otherwise store an array of unique + // suggestions. See the `#suggestionsByKeyword` comment. + let object = this.#suggestionsByKeyword.get(keyword); + if (!object) { + this.#suggestionsByKeyword.set(keyword, suggestion); + } else { + let isArray = Array.isArray(object); + if (!isArray && object != suggestion) { + this.#suggestionsByKeyword.set(keyword, [object, suggestion]); + } else if (isArray && !object.includes(suggestion)) { + object.push(suggestion); + } + } + } + + keywordIndex++; + indexInChunk++; + } + + // The current chunk is done. + resolve(); + }); + }); + } + } + + clear() { + this.#suggestionsByKeyword.clear(); + } + + // Maps each keyword in the dataset to one or more suggestions for the + // keyword. If only one suggestion uses a keyword, the keyword's value in the + // map will be the suggestion object. If more than one suggestion uses the + // keyword, the value will be an array of the suggestions. The reason for not + // always using an array is that we expect the vast majority of keywords to be + // used by only one suggestion, and since there are potentially very many + // keywords and suggestions and we keep them in memory all the time, we want + // to save as much memory as possible. + #suggestionsByKeyword = new Map(); + + // This is only defined as a property so that tests can override it. + static chunkSize = SUGGESTIONS_MAP_CHUNK_SIZE; +} diff --git a/browser/components/urlbar/private/Weather.sys.mjs b/browser/components/urlbar/private/Weather.sys.mjs new file mode 100644 index 0000000000..ea9eddfa2c --- /dev/null +++ b/browser/components/urlbar/private/Weather.sys.mjs @@ -0,0 +1,499 @@ +/* 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", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.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", +}; + +/** + * A feature that periodically fetches weather suggestions from Merino. + */ +export class Weather extends BaseFeature { + 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") && + lazy.UrlbarPrefs.get("merinoEnabled") + ); + } + + get enablingPreferences() { + return ["suggest.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 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 remote settings + */ + get minKeywordLength() { + let minLength = + lazy.UrlbarPrefs.get("weather.minKeywordLength") || + lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength") || + this.#rsData?.min_keyword_length || + 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 cap = + lazy.UrlbarPrefs.get("weatherKeywordsMinimumLengthCap") || + this.#rsData?.min_keyword_length_cap || + 0; + return !cap || this.minKeywordLength < cap; + } + + 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 it, but only + // if the feature was already enabled because if it wasn't, `enable(true)` + // was just called, which calls `#init()`, which calls `#updateConfig()`. + if (wasEnabled && this.isEnabled) { + this.#updateConfig(); + } + } + + 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 = lazy.PromiseUtils.defer(); + } + 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 (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + this.logger.debug("Got weather records: " + JSON.stringify(records)); + this.#rsData = records?.[0]?.weather; + this.#updateConfig(); + } + + 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 the config and listen for changes that + // affect the config. Suggestion fetches will not start until a config has + // been either synced from remote settings or set by Nimbus. + this.#updateConfig(); + lazy.UrlbarPrefs.addObserver(this); + lazy.QuickSuggestRemoteSettings.register(this); + } + + #uninit() { + this.#stopFetching(); + lazy.QuickSuggestRemoteSettings.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); + } + + #updateConfig() { + this.logger.debug("Starting config update"); + + // Get the full keywords, preferring Nimbus over remote settings. + let fullKeywords = + lazy.UrlbarPrefs.get("weatherKeywords") ?? this.#rsData?.keywords; + if (!fullKeywords) { + this.logger.debug("No keywords defined, stopping suggestion fetching"); + this.#keywords = null; + this.#stopFetching(); + return; + } + + 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.#updateConfig(); + } + } + + 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; + #rsData = null; + #suggestion = null; + #timeoutMs = MERINO_TIMEOUT_MS; + #waitForFetchesDeferred = null; +} diff --git a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs new file mode 100644 index 0000000000..399fb7cc11 --- /dev/null +++ b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs @@ -0,0 +1,1432 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +export var UrlbarTestUtils = { + /** + * This maps the categories used by the FX_URLBAR_SELECTED_RESULT_METHOD and + * FX_SEARCHBAR_SELECTED_RESULT_METHOD histograms to their indexes in the + * `labels` array. This only needs to be used by tests that need to map from + * category names to indexes in histogram snapshots. Actual app code can use + * these category names directly when they add to a histogram. + */ + SELECTED_RESULT_METHODS: { + enter: 0, + enterSelection: 1, + click: 2, + arrowEnterSelection: 3, + tabEnterSelection: 4, + rightClickEnter: 5, + }, + + // Fallback to the console. + info: console.log, + + /** + * Running this init allows helpers to access test scope helpers, like Assert + * and SimpleTest. Note this initialization is not enforced, thus helpers + * should always check the properties set here and provide a fallback path. + * + * @param {object} scope The global scope where tests are being run. + */ + init(scope) { + if (!scope) { + throw new Error("Must initialize UrlbarTestUtils with a test scope"); + } + // If you add other properties to `this`, null them in uninit(). + this.Assert = scope.Assert; + this.info = scope.info; + this.registerCleanupFunction = scope.registerCleanupFunction; + + if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + this.initXPCShellDependencies(); + } else { + // xpcshell doesn't support EventUtils. + this.EventUtils = scope.EventUtils; + this.SimpleTest = scope.SimpleTest; + } + + this.registerCleanupFunction(() => { + this.Assert = null; + this.info = console.log; + this.registerCleanupFunction = null; + this.EventUtils = null; + this.SimpleTest = null; + }); + }, + + /** + * Waits to a search to be complete. + * + * @param {object} win The window containing the urlbar + * @returns {Promise} Resolved when done. + */ + async promiseSearchComplete(win) { + let waitForQuery = () => { + return this.promisePopupOpen(win, () => {}).then( + () => win.gURLBar.lastQueryContextPromise + ); + }; + let context = await waitForQuery(); + if (win.gURLBar.searchMode) { + // Search mode may start a second query. + context = await waitForQuery(); + } + return context; + }, + + /** + * Starts a search for a given string and waits for the search to be complete. + * + * @param {object} options The options object. + * @param {object} options.window The window containing the urlbar + * @param {string} options.value the search string + * @param {Function} options.waitForFocus The SimpleTest function + * @param {boolean} [options.fireInputEvent] whether an input event should be + * used when starting the query (simulates the user's typing, sets + * userTypedValued, triggers engagement event telemetry, etc.) + * @param {number} [options.selectionStart] The input's selectionStart + * @param {number} [options.selectionEnd] The input's selectionEnd + */ + async promiseAutocompleteResultPopup({ + window, + value, + waitForFocus, + fireInputEvent = true, + selectionStart = -1, + selectionEnd = -1, + } = {}) { + if (this.SimpleTest) { + await this.SimpleTest.promiseFocus(window); + } else { + await new Promise(resolve => waitForFocus(resolve, window)); + } + + const setup = () => { + window.gURLBar.inputField.focus(); + // Using the value setter in some cases may trim and fetch unexpected + // results, then pick an alternate path. + if ( + lazy.UrlbarPrefs.get("trimURLs") && + value != lazy.BrowserUIUtils.trimURL(value) + ) { + window.gURLBar.inputField.value = value; + fireInputEvent = true; + } else { + window.gURLBar.value = value; + } + if (selectionStart >= 0 && selectionEnd >= 0) { + window.gURLBar.selectionEnd = selectionEnd; + window.gURLBar.selectionStart = selectionStart; + } + + // An input event will start a new search, so be careful not to start a + // search if we fired an input event since that would start two searches. + if (fireInputEvent) { + // This is necessary to get the urlbar to set gBrowser.userTypedValue. + this.fireInputEvent(window); + } else { + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.startQuery(); + } + }; + setup(); + + // In Linux TV test, as there is case that the input field lost the focus + // until showing popup, timeout failure happens since the expected poup + // never be shown. To avoid this, if losing the focus, retry setup to open + // popup. + const blurListener = () => { + setup(); + }; + window.gURLBar.inputField.addEventListener("blur", blurListener, { + once: true, + }); + const result = await this.promiseSearchComplete(window); + window.gURLBar.inputField.removeEventListener("blur", blurListener); + return result; + }, + + /** + * Waits for a result to be added at a certain index. Since we implement lazy + * results replacement, even if we have a result at an index, it may be + * related to the previous query, this methods ensures the result is current. + * + * @param {object} win The window containing the urlbar + * @param {number} index The index to look for + * @returns {HtmlElement|XulElement} the result's element. + */ + async waitForAutocompleteResultAt(win, index) { + // TODO Bug 1530338: Quantum Bar doesn't yet implement lazy results replacement. + await this.promiseSearchComplete(win); + let container = this.getResultsContainer(win); + if (index >= container.children.length) { + throw new Error("Not enough results"); + } + return container.children[index]; + }, + + /** + * Returns the oneOffSearchButtons object for the urlbar. + * + * @param {object} win The window containing the urlbar + * @returns {object} The oneOffSearchButtons + */ + getOneOffSearchButtons(win) { + return win.gURLBar.view.oneOffSearchButtons; + }, + + /** + * Returns a specific button of a result. + * + * @param {object} win The window containing the urlbar + * @param {string} buttonName The name of the button, e.g. "menu", "0", etc. + * @param {number} resultIndex The index of the result + * @returns {HtmlElement} The button + */ + getButtonForResultIndex(win, buttonName, resultIndex) { + return this.getRowAt(win, resultIndex).querySelector( + `.urlbarView-button-${buttonName}` + ); + }, + + /** + * Show the result menu button regardless of the result being hovered or + + selected. + * + * @param {object} win The window containing the urlbar + */ + disableResultMenuAutohide(win) { + let container = this.getResultsContainer(win); + let attr = "disable-resultmenu-autohide"; + container.toggleAttribute(attr, true); + this.registerCleanupFunction?.(() => { + container.toggleAttribute(attr, false); + }); + }, + + /** + * Opens the result menu of a specific result. + * + * @param {object} win The window containing the urlbar + * @param {object} [options] The options object. + * @param {number} [options.resultIndex] The index of the result. Defaults + * to the current selected index. + * @param {boolean} [options.byMouse] Whether to open the menu by mouse or + * keyboard. + * @param {string} [options.activationKey] Key to activate the button with, + * defaults to KEY_Enter. + */ + async openResultMenu( + win, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + byMouse = false, + activationKey = "KEY_Enter", + } = {} + ) { + this.Assert?.ok(win.gURLBar.view.isOpen, "view should be open"); + let menuButton = this.getButtonForResultIndex(win, "menu", resultIndex); + this.Assert?.ok( + menuButton, + `found the menu button at result index ${resultIndex}` + ); + let promiseMenuOpen = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "popupshown" + ); + if (byMouse) { + this.info( + `synthesizing mousemove on row to make the menu button visible` + ); + await this.EventUtils.promiseElementReadyForUserInput( + menuButton.closest(".urlbarView-row"), + win, + this.info + ); + this.info(`got mousemove, now clicking the menu button`); + this.EventUtils.synthesizeMouseAtCenter(menuButton, {}, win); + this.info(`waiting for the menu popup to open via mouse`); + } else { + this.info(`selecting the result at index ${resultIndex}`); + while (win.gURLBar.view.selectedRowIndex != resultIndex) { + this.EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + if (this.getSelectedElement(win) != menuButton) { + this.EventUtils.synthesizeKey("KEY_Tab", {}, win); + } + this.Assert?.equal( + this.getSelectedElement(win), + menuButton, + `selected the menu button at result index ${resultIndex}` + ); + this.EventUtils.synthesizeKey(activationKey, {}, win); + this.info(`waiting for ${activationKey} to open the menu popup`); + } + await promiseMenuOpen; + this.Assert?.equal( + win.gURLBar.view.resultMenu.state, + "open", + "Checking popup state" + ); + }, + + /** + * Opens the result menu of a specific result and gets a menu item by either + * accesskey or command name. Either `accesskey` or `command` must be given. + * + * @param {object} options + * The options object. + * @param {object} options.window + * The window containing the urlbar. + * @param {string} options.accesskey + * The access key of the menu item to return. + * @param {string} options.command + * The command name of the menu item to return. + * @param {number} options.resultIndex + * The index of the result. Defaults to the current selected index. + * @param {boolean} options.openByMouse + * Whether to open the menu by mouse or keyboard. + * @param {Array} options.submenuSelectors + * If the command is in the top-level result menu, leave this as an empty + * array. If it's in a submenu, set this to an array where each element i is + * a selector that can be used to get the i'th menu item that opens a + * submenu. + */ + async openResultMenuAndGetItem({ + window, + accesskey, + command, + resultIndex = window.gURLBar.view.selectedRowIndex, + openByMouse = false, + submenuSelectors = [], + }) { + await this.openResultMenu(window, { resultIndex, byMouse: openByMouse }); + + // Open the sequence of submenus that contains the item. + for (let selector of submenuSelectors) { + let menuitem = window.gURLBar.view.resultMenu.querySelector(selector); + if (!menuitem) { + throw new Error("Submenu item not found for selector: " + selector); + } + + let promisePopup = lazy.BrowserTestUtils.waitForEvent( + window.gURLBar.view.resultMenu, + "popupshown" + ); + + if (AppConstants.platform == "macosx") { + // Synthesized clicks don't work in the native Mac menu. + this.info( + "Calling openMenu() on submenu item with selector: " + selector + ); + menuitem.openMenu(true); + } else { + this.info("Clicking submenu item with selector: " + selector); + this.EventUtils.synthesizeMouseAtCenter(menuitem, {}, window); + } + + this.info("Waiting for submenu popupshown event"); + await promisePopup; + this.info("Got the submenu popupshown event"); + } + + // Now get the item. + let menuitem; + if (accesskey) { + await lazy.BrowserTestUtils.waitForCondition(() => { + menuitem = window.gURLBar.view.resultMenu.querySelector( + `menuitem[accesskey=${accesskey}]` + ); + return menuitem; + }, "Waiting for strings to load"); + } else if (command) { + menuitem = window.gURLBar.view.resultMenu.querySelector( + `menuitem[data-command=${command}]` + ); + } else { + throw new Error("accesskey or command must be specified"); + } + + return menuitem; + }, + + /** + * Opens the result menu of a specific result and presses an access key to + * activate a menu item. + * + * @param {object} win The window containing the urlbar + * @param {string} accesskey The access key to press once the menu is open + * @param {object} [options] The options object. + * @param {number} [options.resultIndex] The index of the result. Defaults + * to the current selected index. + * @param {boolean} [options.openByMouse] Whether to open the menu by mouse + * or keyboard. + */ + async openResultMenuAndPressAccesskey( + win, + accesskey, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + openByMouse = false, + } = {} + ) { + let menuitem = await this.openResultMenuAndGetItem({ + accesskey, + resultIndex, + openByMouse, + window: win, + }); + if (!menuitem) { + throw new Error("Menu item not found for accesskey: " + accesskey); + } + + let promiseCommand = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "command" + ); + + if (AppConstants.platform == "macosx") { + // The native Mac menu doesn't support access keys. + this.info("calling doCommand() to activate menu item"); + menuitem.doCommand(); + win.gURLBar.view.resultMenu.hidePopup(true); + } else { + this.info(`pressing access key (${accesskey}) to activate menu item`); + this.EventUtils.synthesizeKey(accesskey, {}, win); + } + + this.info("waiting for command event"); + await promiseCommand; + this.info("got the command event"); + }, + + /** + * Opens the result menu of a specific result and clicks a menu item with a + * specified command name. + * + * @param {object} win + * The window containing the urlbar. + * @param {string|Array} commandOrArray + * If the command is in the top-level result menu, set this to the command + * name. If it's in a submenu, set this to an array where each element i is + * a selector that can be used to click the i'th menu item that opens a + * submenu, and the last element is the command name. + * @param {object} options + * The options object. + * @param {number} options.resultIndex + * The index of the result. Defaults to the current selected index. + * @param {boolean} options.openByMouse + * Whether to open the menu by mouse or keyboard. + */ + async openResultMenuAndClickItem( + win, + commandOrArray, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + openByMouse = false, + } = {} + ) { + let submenuSelectors = Array.isArray(commandOrArray) + ? commandOrArray + : [commandOrArray]; + let command = submenuSelectors.pop(); + + let menuitem = await this.openResultMenuAndGetItem({ + resultIndex, + openByMouse, + command, + submenuSelectors, + window: win, + }); + if (!menuitem) { + throw new Error("Menu item not found for command: " + command); + } + + let promiseCommand = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "command" + ); + + if (AppConstants.platform == "macosx") { + // Synthesized clicks don't work in the native Mac menu. + this.info("calling doCommand() to activate menu item"); + menuitem.doCommand(); + win.gURLBar.view.resultMenu.hidePopup(true); + } else { + this.info("Clicking menu item with command: " + command); + this.EventUtils.synthesizeMouseAtCenter(menuitem, {}, win); + } + + this.info("Waiting for command event"); + await promiseCommand; + this.info("Got the command event"); + }, + + /** + * Returns true if the oneOffSearchButtons are visible. + * + * @param {object} win The window containing the urlbar + * @returns {boolean} True if the buttons are visible. + */ + getOneOffSearchButtonsVisible(win) { + let buttons = this.getOneOffSearchButtons(win); + return buttons.style.display != "none" && !buttons.container.hidden; + }, + + /** + * Gets an abstracted representation of the result at an index. + * + * @param {object} win The window containing the urlbar + * @param {number} index The index to look for + * @returns {object} An object with numerous properties describing the result. + */ + async getDetailsOfResultAt(win, index) { + let element = await this.waitForAutocompleteResultAt(win, index); + let details = {}; + let result = element.result; + details.result = result; + let { url, postData } = UrlbarUtils.getUrlFromResult(result); + details.url = url; + details.postData = postData; + details.type = result.type; + details.source = result.source; + details.heuristic = result.heuristic; + details.autofill = !!result.autofill; + details.image = + element.getElementsByClassName("urlbarView-favicon")[0]?.src; + details.title = result.title; + details.tags = "tags" in result.payload ? result.payload.tags : []; + details.isSponsored = result.payload.isSponsored; + let actions = element.getElementsByClassName("urlbarView-action"); + let urls = element.getElementsByClassName("urlbarView-url"); + let typeIcon = element.querySelector(".urlbarView-type-icon"); + await win.document.l10n.translateFragment(element); + details.displayed = { + title: element.getElementsByClassName("urlbarView-title")[0]?.textContent, + action: actions.length ? actions[0].textContent : null, + url: urls.length ? urls[0].textContent : null, + typeIcon: typeIcon + ? win.getComputedStyle(typeIcon)["background-image"] + : null, + }; + details.element = { + action: element.getElementsByClassName("urlbarView-action")[0], + row: element, + separator: element.getElementsByClassName( + "urlbarView-title-separator" + )[0], + title: element.getElementsByClassName("urlbarView-title")[0], + url: element.getElementsByClassName("urlbarView-url")[0], + }; + if (details.type == UrlbarUtils.RESULT_TYPE.SEARCH) { + details.searchParams = { + engine: result.payload.engine, + keyword: result.payload.keyword, + query: result.payload.query, + suggestion: result.payload.suggestion, + inPrivateWindow: result.payload.inPrivateWindow, + isPrivateEngine: result.payload.isPrivateEngine, + }; + } else if (details.type == UrlbarUtils.RESULT_TYPE.KEYWORD) { + details.keyword = result.payload.keyword; + } else if (details.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + details.dynamicType = result.payload.dynamicType; + } + return details; + }, + + /** + * Gets the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {HtmlElement|XulElement} The selected element. + */ + getSelectedElement(win) { + return win.gURLBar.view.selectedElement || null; + }, + + /** + * Gets the index of the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {number} The selected index. + */ + getSelectedElementIndex(win) { + return win.gURLBar.view.selectedElementIndex; + }, + + /** + * Gets the row at a specific index. + * + * @param {object} win The window containing the urlbar. + * @param {number} index The index to look for. + * @returns {HTMLElement|XulElement} The selected row. + */ + getRowAt(win, index) { + return this.getResultsContainer(win).children.item(index); + }, + + /** + * Gets the currently selected row. If the selected element is a descendant of + * a row, this will return the ancestor row. + * + * @param {object} win The window containing the urlbar. + * @returns {HTMLElement|XulElement} The selected row. + */ + getSelectedRow(win) { + return this.getRowAt(win, this.getSelectedRowIndex(win)); + }, + + /** + * Gets the index of the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {number} The selected row index. + */ + getSelectedRowIndex(win) { + return win.gURLBar.view.selectedRowIndex; + }, + + /** + * Selects the element at the index specified. + * + * @param {object} win The window containing the urlbar. + * @param {index} index The index to select. + */ + setSelectedRowIndex(win, index) { + win.gURLBar.view.selectedRowIndex = index; + }, + + getResultsContainer(win) { + return win.gURLBar.view.panel.querySelector(".urlbarView-results"); + }, + + /** + * Gets the number of results. + * You must wait for the query to be complete before using this. + * + * @param {object} win The window containing the urlbar + * @returns {number} the number of results. + */ + getResultCount(win) { + return this.getResultsContainer(win).children.length; + }, + + /** + * Ensures at least one search suggestion is present. + * + * @param {object} win The window containing the urlbar + * @returns {boolean} whether at least one search suggestion is present. + */ + promiseSuggestionsPresent(win) { + // TODO Bug 1530338: Quantum Bar doesn't yet implement lazy results replacement. When + // we do that, we'll have to be sure the suggestions we find are relevant + // for the current query. For now let's just wait for the search to be + // complete. + return this.promiseSearchComplete(win).then(context => { + // Look for search suggestions. + let firstSearchSuggestionIndex = context.results.findIndex( + r => r.type == UrlbarUtils.RESULT_TYPE.SEARCH && r.payload.suggestion + ); + if (firstSearchSuggestionIndex == -1) { + throw new Error("Cannot find a search suggestion"); + } + return firstSearchSuggestionIndex; + }); + }, + + /** + * Waits for the given number of connections to an http server. + * + * @param {object} httpserver an HTTP Server instance + * @param {number} count Number of connections to wait for + * @returns {Promise} resolved when all the expected connections were started. + */ + promiseSpeculativeConnections(httpserver, count) { + if (!httpserver) { + throw new Error("Must provide an http server"); + } + return lazy.BrowserTestUtils.waitForCondition( + () => httpserver.connectionNumber == count, + "Waiting for speculative connection setup" + ); + }, + + /** + * Waits for the popup to be shown. + * + * @param {object} win The window containing the urlbar + * @param {Function} openFn Function to be used to open the popup. + * @returns {Promise} resolved once the popup is closed + */ + async promisePopupOpen(win, openFn) { + if (!openFn) { + throw new Error("openFn should be supplied to promisePopupOpen"); + } + await openFn(); + if (win.gURLBar.view.isOpen) { + return; + } + this.info("Awaiting for the urlbar panel to open"); + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewOpen() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + this.info("Urlbar panel opened"); + }, + + /** + * Waits for the popup to be hidden. + * + * @param {object} win The window containing the urlbar + * @param {Function} [closeFn] Function to be used to close the popup, if not + * supplied it will default to a closing the popup directly. + * @returns {Promise} resolved once the popup is closed + */ + async promisePopupClose(win, closeFn = null) { + if (closeFn) { + await closeFn(); + } else { + win.gURLBar.view.close(); + } + if (!win.gURLBar.view.isOpen) { + return; + } + this.info("Awaiting for the urlbar panel to close"); + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewClose() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + this.info("Urlbar panel closed"); + }, + + /** + * Open the input field context menu and run a task on it. + * + * @param {nsIWindow} win the current window + * @param {Function} task a task function to run, gets the contextmenu popup + * as argument. + */ + async withContextMenu(win, task) { + let textBox = win.gURLBar.querySelector("moz-input-box"); + let cxmenu = textBox.menupopup; + let openPromise = lazy.BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + this.EventUtils.synthesizeMouseAtCenter( + win.gURLBar.inputField, + { + type: "contextmenu", + button: 2, + }, + win + ); + await openPromise; + // On Mac sometimes the menuitems are not ready. + await new Promise(win.requestAnimationFrame); + try { + await task(cxmenu); + } finally { + // Close the context menu if the task didn't pick anything. + if (cxmenu.state == "open" || cxmenu.state == "showing") { + let closePromise = lazy.BrowserTestUtils.waitForEvent( + cxmenu, + "popuphidden" + ); + cxmenu.hidePopup(); + await closePromise; + } + } + }, + + /** + * @param {object} win The browser window + * @returns {boolean} Whether the popup is open + */ + isPopupOpen(win) { + return win.gURLBar.view.isOpen; + }, + + /** + * Asserts that the input is in a given search mode, or no search mode. Can + * only be used if UrlbarTestUtils has been initialized with init(). + * + * @param {Window} window + * The browser window. + * @param {object} expectedSearchMode + * The expected search mode object. + */ + async assertSearchMode(window, expectedSearchMode) { + this.Assert.equal( + !!window.gURLBar.searchMode, + window.gURLBar.hasAttribute("searchmode"), + "Urlbar should never be in search mode without the corresponding attribute." + ); + + this.Assert.equal( + !!window.gURLBar.searchMode, + !!expectedSearchMode, + "gURLBar.searchMode should exist as expected" + ); + + if ( + window.gURLBar.searchMode?.source && + window.gURLBar.searchMode.source !== UrlbarUtils.RESULT_SOURCE.SEARCH + ) { + this.Assert.equal( + window.gURLBar.getAttribute("searchmodesource"), + UrlbarUtils.getResultSourceName(window.gURLBar.searchMode.source), + "gURLBar has proper searchmodesource attribute" + ); + } else { + this.Assert.ok( + !window.gURLBar.hasAttribute("searchmodesource"), + "gURLBar does not have searchmodesource attribute" + ); + } + + if (!expectedSearchMode) { + // Check the input's placeholder. + const prefName = + "browser.urlbar.placeholderName" + + (lazy.PrivateBrowsingUtils.isWindowPrivate(window) ? ".private" : ""); + let engineName = Services.prefs.getStringPref(prefName, ""); + this.Assert.deepEqual( + window.document.l10n.getAttributes(window.gURLBar.inputField), + engineName + ? { id: "urlbar-placeholder-with-name", args: { name: engineName } } + : { id: "urlbar-placeholder", args: null }, + "Expected placeholder l10n when search mode is inactive" + ); + return; + } + + // Default to full search mode for less verbose tests. + expectedSearchMode = { ...expectedSearchMode }; + if (!expectedSearchMode.hasOwnProperty("isPreview")) { + expectedSearchMode.isPreview = false; + } + + let isGeneralPurposeEngine = false; + if (expectedSearchMode.engineName) { + let engine = Services.search.getEngineByName( + expectedSearchMode.engineName + ); + isGeneralPurposeEngine = engine.isGeneralPurposeEngine; + expectedSearchMode.isGeneralPurposeEngine = isGeneralPurposeEngine; + } + + // expectedSearchMode may come from UrlbarUtils.LOCAL_SEARCH_MODES. The + // objects in that array include useful metadata like icon URIs and pref + // names that are not usually included in actual search mode objects. For + // convenience, ignore those properties if they aren't also present in the + // urlbar's actual search mode object. + let ignoreProperties = ["icon", "pref", "restrict", "telemetryLabel"]; + for (let prop of ignoreProperties) { + if (prop in expectedSearchMode && !(prop in window.gURLBar.searchMode)) { + this.info( + `Ignoring unimportant property '${prop}' in expected search mode` + ); + delete expectedSearchMode[prop]; + } + } + + this.Assert.deepEqual( + window.gURLBar.searchMode, + expectedSearchMode, + "Expected searchMode" + ); + + // Check the textContent and l10n attributes of the indicator and label. + let expectedTextContent = ""; + let expectedL10n = { id: null, args: null }; + if (expectedSearchMode.engineName) { + expectedTextContent = expectedSearchMode.engineName; + } else if (expectedSearchMode.source) { + let name = UrlbarUtils.getResultSourceName(expectedSearchMode.source); + this.Assert.ok(name, "Expected result source should have a name"); + expectedL10n = { id: `urlbar-search-mode-${name}`, args: null }; + } else { + this.Assert.ok(false, "Unexpected searchMode"); + } + + for (let element of [ + window.gURLBar._searchModeIndicatorTitle, + window.gURLBar._searchModeLabel, + ]) { + if (expectedTextContent) { + this.Assert.equal( + element.textContent, + expectedTextContent, + "Expected textContent" + ); + } + this.Assert.deepEqual( + window.document.l10n.getAttributes(element), + expectedL10n, + "Expected l10n" + ); + } + + // Check the input's placeholder. + let expectedPlaceholderL10n; + if (expectedSearchMode.engineName) { + expectedPlaceholderL10n = { + id: isGeneralPurposeEngine + ? "urlbar-placeholder-search-mode-web-2" + : "urlbar-placeholder-search-mode-other-engine", + args: { name: expectedSearchMode.engineName }, + }; + } else if (expectedSearchMode.source) { + let name = UrlbarUtils.getResultSourceName(expectedSearchMode.source); + expectedPlaceholderL10n = { + id: `urlbar-placeholder-search-mode-other-${name}`, + args: null, + }; + } + this.Assert.deepEqual( + window.document.l10n.getAttributes(window.gURLBar.inputField), + expectedPlaceholderL10n, + "Expected placeholder l10n when search mode is active" + ); + + // If this is an engine search mode, check that all results are either + // search results with the same engine or have the same host as the engine. + // Search mode preview can show other results since it is not supposed to + // start a query. + if ( + expectedSearchMode.engineName && + !expectedSearchMode.isPreview && + this.isPopupOpen(window) + ) { + let resultCount = this.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await this.getDetailsOfResultAt(window, i); + if (result.source == UrlbarUtils.RESULT_SOURCE.SEARCH) { + this.Assert.equal( + expectedSearchMode.engineName, + result.searchParams.engine, + "Search mode result matches engine name." + ); + } else { + let engine = Services.search.getEngineByName( + expectedSearchMode.engineName + ); + let engineRootDomain = + lazy.UrlbarSearchUtils.getRootDomainFromEngine(engine); + let resultUrl = new URL(result.url); + this.Assert.ok( + resultUrl.hostname.includes(engineRootDomain), + "Search mode result matches engine host." + ); + } + } + } + }, + + /** + * Enters search mode by clicking a one-off. The view must already be open + * before you call this. Can only be used if UrlbarTestUtils has been + * initialized with init(). + * + * @param {object} window + * The window to operate on. + * @param {object} searchMode + * If given, the one-off matching this search mode will be clicked; it + * should be a full search mode object as described in + * UrlbarInput.setSearchMode. If not given, the first one-off is clicked. + */ + async enterSearchMode(window, searchMode = null) { + this.info(`Enter Search Mode ${JSON.stringify(searchMode)}`); + + // Ensure any pending query is complete. + await this.promiseSearchComplete(window); + + // Ensure the the one-offs are finished rebuilding and visible. + let oneOffs = this.getOneOffSearchButtons(window); + await lazy.TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + this.Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + let buttons = oneOffs.getSelectableButtons(true); + if (!searchMode) { + searchMode = { engineName: buttons[0].engine.name }; + let engine = Services.search.getEngineByName(searchMode.engineName); + if (engine.isGeneralPurposeEngine) { + searchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } + + if (!searchMode.entry) { + searchMode.entry = "oneoff"; + } + + let oneOff = buttons.find(o => + searchMode.engineName + ? o.engine.name == searchMode.engineName + : o.source == searchMode.source + ); + this.Assert.ok(oneOff, "Found one-off button for search mode"); + this.EventUtils.synthesizeMouseAtCenter(oneOff, {}, window); + await this.promiseSearchComplete(window); + this.Assert.ok(this.isPopupOpen(window), "Urlbar view is still open."); + await this.assertSearchMode(window, searchMode); + }, + + /** + * Exits search mode. If neither `backspace` nor `clickClose` is given, we'll + * default to backspacing. Can only be used if UrlbarTestUtils has been + * initialized with init(). + * + * @param {object} window + * The window to operate on. + * @param {object} options + * Options object + * @param {boolean} options.backspace + * Exits search mode by backspacing at the beginning of the search string. + * @param {boolean} options.clickClose + * Exits search mode by clicking the close button on the search mode + * indicator. + * @param {boolean} [options.waitForSearch] + * Whether the test should wait for a search after exiting search mode. + * Defaults to true. + */ + async exitSearchMode( + window, + { backspace, clickClose, waitForSearch = true } = {} + ) { + let urlbar = window.gURLBar; + // If the Urlbar is not extended, ignore the clickClose parameter. The close + // button is not clickable in this state. This state might be encountered on + // Linux, where prefers-reduced-motion is enabled in automation. + if (!urlbar.hasAttribute("breakout-extend") && clickClose) { + if (waitForSearch) { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + urlbar.searchMode = null; + await searchPromise; + } else { + urlbar.searchMode = null; + } + return; + } + + if (!backspace && !clickClose) { + backspace = true; + } + + if (backspace) { + let urlbarValue = urlbar.value; + urlbar.selectionStart = urlbar.selectionEnd = 0; + if (waitForSearch) { + let searchPromise = this.promiseSearchComplete(window); + this.EventUtils.synthesizeKey("KEY_Backspace", {}, window); + await searchPromise; + } else { + this.EventUtils.synthesizeKey("KEY_Backspace", {}, window); + } + this.Assert.equal( + urlbar.value, + urlbarValue, + "Urlbar value hasn't changed." + ); + this.assertSearchMode(window, null); + } else if (clickClose) { + // We need to hover the indicator to make the close button clickable in the + // test. + let indicator = urlbar.querySelector("#urlbar-search-mode-indicator"); + this.EventUtils.synthesizeMouseAtCenter( + indicator, + { type: "mouseover" }, + window + ); + let closeButton = urlbar.querySelector( + "#urlbar-search-mode-indicator-close" + ); + if (waitForSearch) { + let searchPromise = this.promiseSearchComplete(window); + this.EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + await searchPromise; + } else { + this.EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + } + await this.assertSearchMode(window, null); + } + }, + + /** + * Returns the userContextId (container id) for the last search. + * + * @param {object} win The browser window + * @returns {Promise<number>} + * resolved when fetching is complete. Its value is a userContextId + */ + async promiseUserContextId(win) { + const defaultId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; + let context = await win.gURLBar.lastQueryContextPromise; + return context.userContextId || defaultId; + }, + + /** + * Dispatches an input event to the input field. + * + * @param {object} win The browser window + */ + fireInputEvent(win) { + // Set event.data to the last character in the input, for a couple of + // reasons: It simulates the user typing, and it's necessary for autofill. + let event = new InputEvent("input", { + data: win.gURLBar.value[win.gURLBar.value.length - 1] || null, + }); + win.gURLBar.inputField.dispatchEvent(event); + }, + + /** + * Returns a new mock controller. This is useful for xpcshell tests. + * + * @param {object} options Additional options to pass to the UrlbarController + * constructor. + * @returns {UrlbarController} A new controller. + */ + newMockController(options = {}) { + return new lazy.UrlbarController( + Object.assign( + { + input: { + isPrivate: false, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }, + options + ) + ); + }, + + /** + * Initializes some external components used by the urlbar. This is necessary + * in xpcshell tests but not in browser tests. + */ + async initXPCShellDependencies() { + // The FormHistoryStartup component must be initialized since urlbar uses + // form history. + Cc["@mozilla.org/satchel/form-history-startup;1"] + .getService(Ci.nsIObserver) + .observe(null, "profile-after-change", null); + + // This is necessary because UrlbarMuxerUnifiedComplete.sort calls + // Services.search.parseSubmissionURL, so we need engines. + try { + await lazy.AddonTestUtils.promiseStartupManager(); + } catch (error) { + if (!error.message.includes("already started")) { + throw error; + } + } + }, + + /** + * Enrolls in a mock Nimbus feature. + * + * If you call UrlbarPrefs.updateFirefoxSuggestScenario() from an xpcshell + * test, you must call this first to intialize the Nimbus urlbar feature. + * + * @param {object} value + * Define any desired Nimbus variables in this object. + * @param {string} [feature] + * The feature to init. + * @param {string} [enrollmentType] + * The enrollment type, either "rollout" (default) or "config". + * @returns {Function} + * A cleanup function that will unenroll the feature, returns a promise. + */ + async initNimbusFeature( + value = {}, + feature = "urlbar", + enrollmentType = "rollout" + ) { + this.info("initNimbusFeature awaiting ExperimentManager.onStartup"); + await lazy.ExperimentManager.onStartup(); + + this.info("initNimbusFeature awaiting ExperimentAPI.ready"); + await lazy.ExperimentAPI.ready(); + + let method = + enrollmentType == "rollout" + ? "enrollWithRollout" + : "enrollWithFeatureConfig"; + this.info(`initNimbusFeature awaiting ExperimentFakes.${method}`); + let doCleanup = await lazy.ExperimentFakes[method]({ + featureId: lazy.NimbusFeatures[feature].featureId, + value: { enabled: true, ...value }, + }); + + this.info("initNimbusFeature done"); + + this.registerCleanupFunction?.(async () => { + // If `doCleanup()` has already been called (i.e., by the caller), it will + // throw an error here. + try { + await doCleanup(); + } catch (error) {} + }); + + return doCleanup; + }, + + /** + * Simulate that user clicks URLBar and inputs text into it. + * + * @param {object} win + * The browser window containing target gURLBar. + * @param {string} text + * The text to be input. + */ + async inputIntoURLBar(win, text) { + this.EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + await lazy.BrowserTestUtils.waitForCondition( + () => win.document.activeElement === win.gURLBar.inputField + ); + this.EventUtils.sendString(text, win); + }, +}; + +UrlbarTestUtils.formHistory = { + /** + * Adds values to the urlbar's form history. + * + * @param {Array} values + * The form history entries to remove. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + add(values = [], window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.add(fieldname, values); + }, + + /** + * Removes values from the urlbar's form history. If you want to remove all + * history, use clearFormHistory. + * + * @param {Array} values + * The form history entries to remove. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + remove(values = [], window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.remove(fieldname, values); + }, + + /** + * Removes all values from the urlbar's form history. If you want to remove + * individual values, use removeFormHistory. + * + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + clear(window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.clear(fieldname); + }, + + /** + * Searches the urlbar's form history. + * + * @param {object} criteria + * Criteria to narrow the search. See FormHistory.search. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} + * A promise resolved with an array of found form history entries. + */ + search(criteria = {}, window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.search(fieldname, criteria); + }, + + /** + * Returns a promise that's resolved on the next form history change. + * + * @param {string} change + * Null to listen for any change, or one of: add, remove, update + * @returns {Promise} + * Resolved on the next specified form history change. + */ + promiseChanged(change = null) { + return lazy.TestUtils.topicObserved( + "satchel-storage-changed", + (subject, data) => !change || data == "formhistory-" + change + ); + }, + + /** + * Returns the form history name for the urlbar in a window. + * + * @param {object} window + * The window. + * @returns {string} + * The form history name of the urlbar in the window. + */ + getFormHistoryName(window = lazy.BrowserWindowTracker.getTopWindow()) { + return window ? window.gURLBar.formHistoryName : "searchbar-history"; + }, +}; + +/** + * A test provider. If you need a test provider whose behavior is different + * from this, then consider modifying the implementation below if you think the + * new behavior would be useful for other tests. Otherwise, you can create a + * new TestProvider instance and then override its methods. + */ +class TestProvider extends UrlbarProvider { + /** + * Constructor. + * + * @param {object} options + * Constructor options + * @param {Array} options.results + * An array of UrlbarResult objects that will be the provider's results. + * @param {string} [options.name] + * The provider's name. Provider names should be unique. + * @param {UrlbarUtils.PROVIDER_TYPE} [options.type] + * The provider's type. + * @param {number} [options.priority] + * The provider's priority. Built-in providers have a priority of zero. + * @param {number} [options.addTimeout] + * If non-zero, each result will be added on this timeout. If zero, all + * results will be added immediately and synchronously. + * @param {Function} [options.onCancel] + * If given, a function that will be called when the provider's cancelQuery + * method is called. + * @param {Function} [options.onSelection] + * If given, a function that will be called when + * {@link UrlbarView.#selectElement} method is called. + * @param {Function} [options.onEngagement] + * If given, a function that will be called when engagement. + */ + constructor({ + results, + name = "TestProvider" + Services.uuid.generateUUID(), + type = UrlbarUtils.PROVIDER_TYPE.PROFILE, + priority = 0, + addTimeout = 0, + onCancel = null, + onSelection = null, + onEngagement = null, + } = {}) { + super(); + this._results = results; + this._name = name; + this._type = type; + this._priority = priority; + this._addTimeout = addTimeout; + this._onCancel = onCancel; + this._onSelection = onSelection; + this._onEngagement = onEngagement; + } + get name() { + return this._name; + } + get type() { + return this._type; + } + getPriority(context) { + return this._priority; + } + isActive(context) { + return true; + } + async startQuery(context, addCallback) { + for (let result of this._results) { + if (!this._addTimeout) { + addCallback(this, result); + } else { + await new Promise(resolve => { + lazy.setTimeout(() => { + addCallback(this, result); + resolve(); + }, this._addTimeout); + }); + } + } + } + cancelQuery(context) { + if (this._onCancel) { + this._onCancel(); + } + } + + onSelection(result, element) { + if (this._onSelection) { + this._onSelection(result, element); + } + } + + onEngagement(isPrivate, state, queryContext, details) { + if (this._onEngagement) { + this._onEngagement(isPrivate, state, queryContext, details); + } + } +} + +UrlbarTestUtils.TestProvider = TestProvider; diff --git a/browser/components/urlbar/tests/browser-tips/README.txt b/browser/components/urlbar/tests/browser-tips/README.txt new file mode 100644 index 0000000000..04a7b09707 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/README.txt @@ -0,0 +1,7 @@ +If you're running these tests and you get an error like this: + +FAIL head.js import threw an exception - Error opening input stream (invalid filename?): chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/head.js + +Then run `mach test toolkit/mozapps/update/tests/browser` first. You can +stop mach as soon as it starts the first test, but this is necessary so that +mach builds the update tests in your objdir. diff --git a/browser/components/urlbar/tests/browser-tips/browser.ini b/browser/components/urlbar/tests/browser-tips/browser.ini new file mode 100644 index 0000000000..d7674161dc --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser.ini @@ -0,0 +1,26 @@ +# 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/. + +[DEFAULT] +support-files = + head.js + +[browser_interventions.js] +[browser_picks.js] +[browser_searchTips.js] +support-files = + ../browser/slow-page.sjs + slow-page.html +https_first_disabled = true +[browser_searchTips_interaction.js] +https_first_disabled = true +[browser_selection.js] +[browser_updateAsk.js] +skip-if = os == 'win' && msix # Updater is disabled in MSIX builds +[browser_updateRefresh.js] +skip-if = os == 'win' && msix # Updater is disabled in MSIX builds +[browser_updateRestart.js] +skip-if = os == 'win' && msix # Updater is disabled in MSIX builds +[browser_updateWeb.js] +skip-if = os == 'win' && msix # Updater is disabled in MSIX builds diff --git a/browser/components/urlbar/tests/browser-tips/browser_interventions.js b/browser/components/urlbar/tests/browser-tips/browser_interventions.js new file mode 100644 index 0000000000..ebac90ad85 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_interventions.js @@ -0,0 +1,271 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.sys.mjs", +}); + +add_setup(async function () { + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + makeProfileResettable(); +}); + +// Tests the refresh tip. +add_task(async function refresh() { + // Pick the tip, which should open the refresh dialog. Click its cancel + // button. + await checkIntervention({ + searchString: SEARCH_STRINGS.REFRESH, + tip: UrlbarProviderInterventions.TIP_TYPE.REFRESH, + title: + "Restore default settings and remove old add-ons for optimal performance.", + button: /^Refresh .+…$/, + awaitCallback() { + return BrowserTestUtils.promiseAlertDialog( + "cancel", + "chrome://global/content/resetProfile.xhtml", + { isSubDialog: true } + ); + }, + }); +}); + +// Tests the clear tip. +add_task(async function clear() { + // Pick the tip, which should open the refresh dialog. Click its cancel + // button. + await checkIntervention({ + searchString: SEARCH_STRINGS.CLEAR, + tip: UrlbarProviderInterventions.TIP_TYPE.CLEAR, + title: "Clear your cache, cookies, history and more.", + button: "Choose What to Clear…", + awaitCallback() { + return BrowserTestUtils.promiseAlertDialog( + "cancel", + "chrome://browser/content/sanitize.xhtml", + { + isSubDialog: true, + } + ); + }, + }); +}); + +// Tests the clear tip in a private window. The clear tip shouldn't appear in +// private windows. +add_task(async function clear_private() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + // First, make sure the extension works in PBM by triggering a non-clear + // tip. + let result = (await awaitTip(SEARCH_STRINGS.REFRESH, win))[0]; + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.REFRESH + ); + + // Blur the urlbar so that the engagement is ended. + await UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()); + + // Now do a search that would trigger the clear tip. + await awaitNoTip(SEARCH_STRINGS.CLEAR, win); + + // Blur the urlbar so that the engagement is ended. + await UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()); + + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that if multiple interventions of the same type are seen in the same +// engagement, only one instance is recorded in Telemetry. +add_task(async function multipleInterventionsInOneEngagement() { + Services.telemetry.clearScalars(); + let result = (await awaitTip(SEARCH_STRINGS.REFRESH, window))[0]; + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.REFRESH + ); + result = (await awaitTip(SEARCH_STRINGS.CLEAR, window))[0]; + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.CLEAR + ); + result = (await awaitTip(SEARCH_STRINGS.REFRESH, window))[0]; + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.REFRESH + ); + + // Blur the urlbar so that the engagement is ended. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + // We should only record one impression for the Refresh tip. Although it was + // seen twice, it was in the same engagement. + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderInterventions.TIP_TYPE.REFRESH}-shown`, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderInterventions.TIP_TYPE.CLEAR}-shown`, + 1 + ); +}); + +// Test the result of UrlbarProviderInterventions.isActive() +// and whether or not the function calucates the score. +add_task(async function testIsActive() { + const testData = [ + { + description: "Test for search string that activates the intervention", + searchString: "firefox slow", + expectedActive: true, + expectedScoreCalculated: true, + }, + { + description: + "Test for search string that does not activate the intervention", + searchString: "example slow", + expectedActive: false, + expectedScoreCalculated: true, + }, + { + description: "Test for empty search string", + searchString: "", + expectedActive: false, + expectedScoreCalculated: false, + }, + { + description: "Test for an URL", + searchString: "https://firefox/slow", + expectedActive: false, + expectedScoreCalculated: false, + }, + { + description: "Test for a data URL", + searchString: "data:text/html,<div>firefox slow</div>", + expectedActive: false, + expectedScoreCalculated: false, + }, + { + description: "Test for string like URL", + searchString: "firefox://slow", + expectedActive: false, + expectedScoreCalculated: false, + }, + ]; + + for (const { + description, + searchString, + expectedActive, + expectedScoreCalculated, + } of testData) { + info(description); + + // Set null to currentTip to know whether or not UrlbarProviderInterventions + // calculated the score. + UrlbarProviderInterventions.currentTip = null; + + const isActive = UrlbarProviderInterventions.isActive({ searchString }); + Assert.equal(isActive, expectedActive, "Result of isAcitive is correct"); + const isScoreCalculated = UrlbarProviderInterventions.currentTip !== null; + Assert.equal( + isScoreCalculated, + expectedScoreCalculated, + "The score is calculated correctly" + ); + } +}); + +add_task(async function tipsAreEnglishOnly() { + // Test that Interventions are working in en-US. + let result = (await awaitTip(SEARCH_STRINGS.REFRESH, window))[0]; + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.REFRESH + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + // We will need to fetch new engines when we switch locales. + let enginesReloaded = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + + const originalAvailable = Services.locale.availableLocales; + const originalRequested = Services.locale.requestedLocales; + Services.locale.availableLocales = ["en-US", "de"]; + Services.locale.requestedLocales = ["de"]; + + let cleanup = async () => { + let reloadPromise = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Services.locale.requestedLocales = originalRequested; + Services.locale.availableLocales = originalAvailable; + await reloadPromise; + cleanup = null; + }; + registerCleanupFunction(() => cleanup?.()); + + let appLocales = Services.locale.appLocalesAsBCP47; + Assert.equal(appLocales[0], "de"); + + await enginesReloaded; + + // Interventions should no longer work in the new locale. + await awaitNoTip(SEARCH_STRINGS.CLEAR, window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + await cleanup(); +}); + +// Tests the help command (using the clear intervention). It should open the +// help page and it should not trigger the primary intervention behavior. +add_task(async function pickHelp() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search that triggers the clear tip. + let [result] = await awaitTip(SEARCH_STRINGS.CLEAR); + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.CLEAR + ); + + // Click the help command and wait for the help page to load. + Assert.ok( + !!result.payload.helpUrl, + "The result's helpUrl should be defined and non-empty: " + + JSON.stringify(result.payload.helpUrl) + ); + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + result.payload.helpUrl + ); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "h", { + openByMouse: true, + resultIndex: 1, + }); + info("Waiting for help URL to load in the current tab"); + await loadPromise; + + // Wait a bit and make sure the clear recent history dialog did not open. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 2000)); + Assert.strictEqual(gDialogBox.isOpen, false, "No dialog should be open"); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderInterventions.TIP_TYPE.CLEAR}-help`, + 1 + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_picks.js b/browser/components/urlbar/tests/browser-tips/browser_picks.js new file mode 100644 index 0000000000..60a7676668 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_picks.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests clicks and enter key presses on UrlbarUtils.RESULT_TYPE.TIP results. + +"use strict"; + +const TIP_URL = "http://example.com/tip"; +const HELP_URL = "http://example.com/help"; + +add_setup(async function () { + window.windowUtils.disableNonTestMouseEvents(true); + registerCleanupFunction(() => { + window.windowUtils.disableNonTestMouseEvents(false); + }); + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); +}); + +add_task(async function enter_mainButton_url() { + await doTest({ click: false, buttonUrl: TIP_URL }); +}); + +add_task(async function enter_mainButton_noURL() { + await doTest({ click: false }); +}); + +add_task(async function enter_help() { + await doTest({ click: false, helpUrl: HELP_URL }); +}); + +add_task(async function mouse_mainButton_url() { + await doTest({ click: true, buttonUrl: TIP_URL }); +}); + +add_task(async function mouse_mainButton_noURL() { + await doTest({ click: true }); +}); + +add_task(async function mouse_help() { + await doTest({ click: true, helpUrl: HELP_URL }); +}); + +// Clicks inside a tip but not on any button. +add_task(async function mouse_insideTipButNotOnButtons() { + let results = [makeTipResult({ buttonUrl: TIP_URL, helpUrl: HELP_URL })]; + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + // Click inside the tip but outside the buttons. Nothing should happen. Make + // the result the heuristic to check that the selection on the main button + // isn't lost. + results[0].heuristic = true; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + fireInputEvent: true, + }); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The main button's index should be selected initially" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + row._buttons.get("0"), + "The main button element should be selected initially" + ); + EventUtils.synthesizeMouseAtCenter(row, {}); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 500)); + Assert.ok(gURLBar.view.isOpen, "The view should remain open"); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The main button's index should remain selected" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + row._buttons.get("0"), + "The main button element should remain selected" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +/** + * Runs this test's main checks. + * + * @param {object} options + * Options for the test. + * @param {boolean} options.click + * Pass true to trigger a click, false to trigger an enter key. + * @param {string} [options.buttonUrl] + * Pass a URL if picking the main button should open a URL. Pass nothing if + * a URL shouldn't be opened or if you want to pick the help button instead of + * the main button. + * @param {string} [options.helpUrl] + * Pass a URL if you want to pick the help button. Pass nothing if you want + * to pick the main button instead. + */ +async function doTest({ click, buttonUrl = undefined, helpUrl = undefined }) { + // Open a new tab for the test if we expect to load a URL. + let tab; + if (buttonUrl || helpUrl) { + tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:blank", + }); + } + + // Add our test provider. + let provider = new UrlbarTestUtils.TestProvider({ + results: [makeTipResult({ buttonUrl, helpUrl })], + priority: 1, + }); + UrlbarProvidersManager.registerProvider(provider); + + let onEngagementPromise = new Promise( + resolve => (provider.onEngagement = resolve) + ); + + // Do a search to show our tip result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + fireInputEvent: true, + }); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + let mainButton = row._buttons.get("0"); + let target = helpUrl + ? row._buttons.get(UrlbarPrefs.get("resultMenu") ? "menu" : "help") + : mainButton; + + // If we're picking the tip with the keyboard, TAB to select the proper + // target. + if (!click) { + EventUtils.synthesizeKey("KEY_Tab", { repeat: helpUrl ? 2 : 1 }); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + target, + `${target.className} should be selected.` + ); + } + + // Now pick the target and wait for provider.onEngagement to be called and + // the URL to load if necessary. + let loadPromise; + if (buttonUrl || helpUrl) { + loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + } + await UrlbarTestUtils.promisePopupClose(window, () => { + if (helpUrl && UrlbarPrefs.get("resultMenu")) { + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "h", { + openByMouse: click, + resultIndex: 0, + }); + } else if (click) { + EventUtils.synthesizeMouseAtCenter(target, {}); + } else { + EventUtils.synthesizeKey("KEY_Enter"); + } + }); + await onEngagementPromise; + await loadPromise; + + // Check telemetry. + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + helpUrl ? "test-help" : "test-picked", + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: + click && !(helpUrl && UrlbarPrefs.get("resultMenu")) + ? "click" + : "enter", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + // Done. + UrlbarProvidersManager.unregisterProvider(provider); + if (tab) { + BrowserTestUtils.removeTab(tab); + } +} + +function makeTipResult({ buttonUrl, helpUrl }) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: buttonUrl, + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + helpUrl, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }, + } + ); +} diff --git a/browser/components/urlbar/tests/browser-tips/browser_searchTips.js b/browser/components/urlbar/tests/browser-tips/browser_searchTips.js new file mode 100644 index 0000000000..ff592bc831 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips.js @@ -0,0 +1,657 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the Search Tips feature, which displays a prompt to use the Urlbar on +// the newtab page and on the user's default search engine's homepage. +// Specifically, it tests that the Tips appear when they should be appearing. +// This doesn't test the max-shown-count limit or the restriction on tips when +// we show the default browser prompt because those require restarting the +// browser. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderSearchTips: + "resource:///modules/UrlbarProviderSearchTips.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +// These should match the same consts in UrlbarProviderSearchTips.jsm. +const MAX_SHOWN_COUNT = 4; +const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +// We test some of the bigger Google domains. +const GOOGLE_DOMAINS = [ + "www.google.com", + "www.google.ca", + "www.google.co.uk", + "www.google.com.au", + "www.google.co.nz", +]; + +// In order for the persist tip to appear, the scheme of the +// search engine has to be the same as the scheme of the SERP url. +// withDNSRedirect() loads an http: url while the searchform +// of the default engine uses https. To enable the search term +// to be shown, we use the Example engine because it doesn't require +// a redirect. +const SEARCH_SERP_URL = "https://example.com/?q=chocolate"; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}`, + 0, + ], + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}`, + 0, + ], + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}`, + 0, + ], + ], + }); + + // Write an old profile age so tips are actually shown. + let age = await ProfileAge(); + let originalTimes = age._times; + let date = Date.now() - LAST_UPDATE_THRESHOLD_MS - 30000; + age._times = { created: date, firstUse: date }; + await age.writeTimes(); + + // Remove update history and the current active update so tips are shown. + let updateRootDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile); + let updatesFile = updateRootDir.clone(); + updatesFile.append("updates.xml"); + let activeUpdateFile = updateRootDir.clone(); + activeUpdateFile.append("active-update.xml"); + try { + updatesFile.remove(false); + } catch (e) {} + try { + activeUpdateFile.remove(false); + } catch (e) {} + + let defaultEngine = await Services.search.getDefault(); + let defaultEngineName = defaultEngine.name; + Assert.equal(defaultEngineName, "Google", "Default engine should be Google."); + + // Add a mock engine so we don't hit the network loading the SERP. + await SearchTestUtils.installSearchExtension(); + + registerCleanupFunction(async () => { + let age2 = await ProfileAge(); + age2._times = originalTimes; + await age2.writeTimes(); + await setDefaultEngine(defaultEngineName); + resetSearchTipsProvider(); + }); +}); + +// The onboarding tip should be shown on about:newtab. +add_task(async function newtab() { + await checkTab( + window, + "about:newtab", + UrlbarProviderSearchTips.TIP_TYPE.ONBOARD + ); +}); + +// The onboarding tip should be shown on about:home. +add_task(async function home() { + await checkTab( + window, + "about:home", + UrlbarProviderSearchTips.TIP_TYPE.ONBOARD + ); +}); + +// The redirect tip should be shown for www.google.com when it's the default +// engine. +add_task(async function google() { + await setDefaultEngine("Google"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); + } +}); + +// The redirect tip should be shown for www.google.com/webhp when it's the +// default engine. +add_task(async function googleWebhp() { + await setDefaultEngine("Google"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/webhp", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); + } +}); + +// The redirect tip should be shown for the Google homepage when query strings +// are appended. +add_task(async function googleQueryString() { + await setDefaultEngine("Google"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/webhp", async url => { + await checkTab( + window, + `${url}?hl=en`, + UrlbarProviderSearchTips.TIP_TYPE.REDIRECT + ); + }); + } +}); + +// The redirect tip should not be shown on Google results pages. +add_task(async function googleResults() { + await setDefaultEngine("Google"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/search", async url => { + await checkTab( + window, + `${url}?q=firefox`, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); + }); + } +}); + +// The redirect tip should not be shown for www.google.com when it's not the +// default engine. +add_task(async function googleNotDefault() { + await setDefaultEngine("Bing"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); + } +}); + +// The redirect tip should not be shown for www.google.com/webhp when it's not +// the default engine. +add_task(async function googleWebhpNotDefault() { + await setDefaultEngine("Bing"); + for (let domain of GOOGLE_DOMAINS) { + await withDNSRedirect(domain, "/webhp", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); + } +}); + +// The redirect tip should be shown for www.bing.com when it's the default +// engine. +add_task(async function bing() { + await setDefaultEngine("Bing"); + await withDNSRedirect("www.bing.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); +}); + +// The redirect tip should be shown on the Bing homepage even when Bing appends +// query strings. +add_task(async function bingQueryString() { + await setDefaultEngine("Bing"); + await withDNSRedirect("www.bing.com", "/", async url => { + await checkTab( + window, + `${url}?toWww=1`, + UrlbarProviderSearchTips.TIP_TYPE.REDIRECT + ); + }); +}); + +// The redirect tip should not be shown on Bing results pages. +add_task(async function bingResults() { + await setDefaultEngine("Bing"); + await withDNSRedirect("www.bing.com", "/search", async url => { + await checkTab( + window, + `${url}?q=firefox`, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); + }); +}); + +// The redirect tip should not be shown for www.bing.com when it's not the +// default engine. +add_task(async function bingNotDefault() { + await setDefaultEngine("Google"); + await withDNSRedirect("www.bing.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); +}); + +// The redirect tip should be shown for duckduckgo.com when it's the default +// engine. +add_task(async function ddg() { + await setDefaultEngine("DuckDuckGo"); + await withDNSRedirect("duckduckgo.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); +}); + +// The redirect tip should be shown for start.duckduckgo.com when it's the +// default engine. +add_task(async function ddgStart() { + await setDefaultEngine("DuckDuckGo"); + await withDNSRedirect("start.duckduckgo.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); +}); + +// The redirect tip should not be shown for duckduckgo.com when it's not the +// default engine. +add_task(async function ddgNotDefault() { + await setDefaultEngine("Google"); + await withDNSRedirect("duckduckgo.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); +}); + +// The redirect tip should not be shown for start.duckduckgo.com when it's not +// the default engine. +add_task(async function ddgStartNotDefault() { + await setDefaultEngine("Google"); + await withDNSRedirect("start.duckduckgo.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); +}); + +// The redirect tip should not be shown for duckduckgo.com/?q=foo, the search +// results page, which happens to have the same domain and path as the home +// page. +add_task(async function ddgSearchResultsPage() { + await setDefaultEngine("DuckDuckGo"); + await withDNSRedirect("duckduckgo.com", "/", async url => { + await checkTab( + window, + `${url}?q=test`, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); + }); +}); + +// The redirect tip should not be shown on a non-engine page. +add_task(async function nonEnginePage() { + await checkTab( + window, + "http://example.com/", + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); +}); + +// The persist tip should show on default SERPs. +// This test also has an implied check that the SERP +// is receiving an originalURI. +// This is because the page the test is attempting to load +// will differ from the page that's actually loaded due to +// the DNS redirect. +add_task(async function persistTipOnDefault() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + await checkTab( + window, + SEARCH_SERP_URL, + UrlbarProviderSearchTips.TIP_TYPE.PERSIST + ); + await SpecialPowers.popPrefEnv(); +}); + +// The persist tip should not show on non-default SERPs. +add_task(async function noPersistTipOnNonDefault() { + await setDefaultEngine("DuckDuckGo"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + await checkTab( + window, + SEARCH_SERP_URL, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); + await SpecialPowers.popPrefEnv(); +}); + +// The persist tip should only show up once a session. +add_task(async function persistTipOnceOnDefaultSerp() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + await checkTab( + window, + SEARCH_SERP_URL, + UrlbarProviderSearchTips.TIP_TYPE.PERSIST + ); + await checkTab( + window, + SEARCH_SERP_URL, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); + await SpecialPowers.popPrefEnv(); +}); + +// The persist tip should not show in a window +// with a selected tab containing a non-SERP url. +add_task(async function noPersistTipInWindowWithNonSerpTab() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + // Create a new window for the SERP to be loaded into. + let newWindow = await BrowserTestUtils.openNewBrowserWindow(); + + // Focus on the original window. + window.focus(); + await waitForBrowserWindowActive(window); + + // Load the SERP in the new window to initiate a background load. + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + newWindow.gBrowser.selectedBrowser, + false, + SEARCH_SERP_URL + ); + BrowserTestUtils.loadURIString( + newWindow.gBrowser.selectedBrowser, + SEARCH_SERP_URL + ); + await browserLoadedPromise; + + // Wait longer than the persist tip delay to check that the search tip + // doesn't show on the non-SERP tab. + await new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, UrlbarProviderSearchTips.SHOW_PERSIST_TIP_DELAY_MS * 2) + ); + Assert.ok(!window.gURLBar.view.isOpen); + + // Clean up. + await BrowserTestUtils.closeWindow(newWindow); + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); +}); + +// Tips should be shown at most once per session regardless of their type. +add_task(async function oncePerSession() { + await setDefaultEngine("Google"); + await checkTab( + window, + "about:newtab", + UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, + false + ); + await checkTab( + window, + "about:newtab", + UrlbarProviderSearchTips.TIP_TYPE.NONE, + false + ); + await withDNSRedirect("www.google.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.NONE); + }); + await setDefaultEngine("Example"); + await checkTab( + window, + SEARCH_SERP_URL, + UrlbarProviderSearchTips.TIP_TYPE.NONE + ); +}); + +// The one-off search buttons should not be shown when +// a search tip is shown even though the search string is empty. +add_task(async function shortcut_buttons_with_tip() { + await checkTab( + window, + "about:newtab", + UrlbarProviderSearchTips.TIP_TYPE.ONBOARD + ); +}); + +// Don't show the persist search tip when the browser loads +// a different page from the page the tip was supposed to show on. +add_task(async function noSearchTipWhileAnotherPageLoads() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + // Create a slow endpoint. + const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://www.example.com" + ) + "slow-page.sjs"; + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: SEARCH_SERP_URL, + }); + + // Load a slow URI to cause an onStateChange event but + // not an onLocationChange event. + BrowserTestUtils.loadURIString(tab.linkedBrowser, SLOW_PAGE); + + // Wait roughly for the amount of time it would take for the + // persist search tip to show. + await new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, UrlbarProviderSearchTips.SHOW_PERSIST_TIP_DELAY_MS * 2) + ); + + // Check the search tip didn't show while the page was loading. + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ), + 0, + "The shownCount pref should be 0." + ); + + Assert.equal(false, window.gURLBar.view.isOpen, "Urlbar should be closed."); + + // Clean up. + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); + BrowserTestUtils.removeTab(tab); +}); + +// Show the persist search tip when the browser is still loading +// resources from the page the tip is supposed to show on. +add_task(async function searchTipWhilePageLoads() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + // Create a search engine endpoint that will still + // be loading resources on the page load. + const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://www.example.com" + ) + "slow-page.html"; + + await SearchTestUtils.installSearchExtension({ + name: "Slow Engine", + search_url: SLOW_PAGE, + search_url_get_params: "search={searchTerms}", + }); + await setDefaultEngine("Slow Engine"); + + let engine = Services.search.getEngineByName("Slow Engine"); + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, "chocolate"); + + // Load a slow SERP. + await checkTab( + window, + expectedSearchUrl, + UrlbarProviderSearchTips.TIP_TYPE.PERSIST + ); + + // Clean up. + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); +}); + +// Search tips modify the userTypedValue of a tab. The next time +// the pageproxystate is updated, the existence of the userTypedValue +// can change the pageproxystate. In the case of the Persist Search Tip, +// we don't want to change the pageproxystate while the Urlbar is non-focused, +// so check that when an event causes the pageproxystate to update +// (e.g. a SERP pushing state), the pageproxystate remains the same. +add_task(async function persistSearchTipAfterPushState() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: SEARCH_SERP_URL, + }); + + // Ensure the search tip is visible. + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.PERSIST, false); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Urlbar is should be in a valid pageproxystate." + ); + + // Mock the default SERP using the History API on an exising website. + // This is to trigger another call to setURI. + await SpecialPowers.spawn(tab.linkedBrowser, [SEARCH_SERP_URL], async url => { + content.history.pushState({}, "", url); + }); + + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Urlbar is should be in a valid pageproxystate." + ); + + // Clean up. + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); + BrowserTestUtils.removeTab(tab); +}); + +// Ensure a the Persist Search Tip is non-visible when a PopupNotification +// is already visible. +add_task(async function persistSearchTipBeforePopupShown() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: SEARCH_SERP_URL, + }); + + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup.", + "geo-notification-icon" + ); + await promisePopupShown; + + // Wait roughly for the amount of time it would take for the + // persist search tip to show. + await new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, UrlbarProviderSearchTips.SHOW_PERSIST_TIP_DELAY_MS * 2) + ); + + // Check the search tip didn't show while the page was loading. + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ), + 0, + "The shownCount pref should be 0." + ); + Assert.equal(false, window.gURLBar.view.isOpen, "Urlbar should be closed."); + + // Clean up. + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); + BrowserTestUtils.removeTab(tab); +}); + +// The Persist Search Tip should be hidden when a PopupNotification appears. +add_task(async function persistSearchTipAfterPopupShown() { + await setDefaultEngine("Example"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: SEARCH_SERP_URL, + }); + + // Ensure the search tip is visible. + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.PERSIST, false); + + // Show a popup after the search tip is shown. + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup.", + "geo-notification-icon" + ); + await promisePopupShown; + + // The search tip should not be visible. + Assert.equal(false, window.gURLBar.view.isOpen, "Urlbar should be closed."); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Urlbar is should be in a valid pageproxystate." + ); + + // Clean up. + await SpecialPowers.popPrefEnv(); + resetSearchTipsProvider(); + BrowserTestUtils.removeTab(tab); +}); + +function waitForBrowserWindowActive(win) { + return new Promise(resolve => { + if (Services.focus.activeWindow == win) { + resolve(); + } else { + win.addEventListener( + "activate", + () => { + resolve(); + }, + { once: true } + ); + } + }); +} diff --git a/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js b/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js new file mode 100644 index 0000000000..76281dfaf2 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js @@ -0,0 +1,837 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the Search Tips feature, which displays a prompt to use the Urlbar on +// the newtab page and on the user's default search engine's homepage. +// Specifically, it tests that the Tips appear when they should be appearing. +// This doesn't test the max-shown-count limit because it requires restarting +// the browser. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderSearchTips: + "resource:///modules/UrlbarProviderSearchTips.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +// These should match the same consts in UrlbarProviderSearchTips.jsm. +const MAX_SHOWN_COUNT = 4; +const LAST_UPDATE_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +// We test some of the bigger Google domains. +const GOOGLE_DOMAINS = [ + "www.google.com", + "www.google.ca", + "www.google.co.uk", + "www.google.com.au", + "www.google.co.nz", +]; + +// In order for the persist tip to appear, the scheme of the +// search engine has to be the same as the scheme of the SERP url. +// withDNSRedirect() loads an http: url while the searchform +// of the default engine uses https. To enable the search term +// to be shown, we use the Example engine because it doesn't require +// a redirect. +const SEARCH_TERM = "chocolate"; +const SEARCH_SERP_URL = `https://example.com/?q=${SEARCH_TERM}`; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}`, + 0, + ], + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}`, + 0, + ], + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}`, + 0, + ], + ], + }); + + // Write an old profile age so tips are actually shown. + let age = await ProfileAge(); + let originalTimes = age._times; + let date = Date.now() - LAST_UPDATE_THRESHOLD_MS - 30000; + age._times = { created: date, firstUse: date }; + await age.writeTimes(); + + // Remove update history and the current active update so tips are shown. + let updateRootDir = Services.dirsvc.get("UpdRootD", Ci.nsIFile); + let updatesFile = updateRootDir.clone(); + updatesFile.append("updates.xml"); + let activeUpdateFile = updateRootDir.clone(); + activeUpdateFile.append("active-update.xml"); + try { + updatesFile.remove(false); + } catch (e) {} + try { + activeUpdateFile.remove(false); + } catch (e) {} + + let defaultEngine = await Services.search.getDefault(); + let defaultEngineName = defaultEngine.name; + Assert.equal(defaultEngineName, "Google", "Default engine should be Google."); + + // Add a mock engine so we don't hit the network loading the SERP. + await SearchTestUtils.installSearchExtension(); + + registerCleanupFunction(async () => { + let age2 = await ProfileAge(); + age2._times = originalTimes; + await age2.writeTimes(); + await setDefaultEngine(defaultEngineName); + resetSearchTipsProvider(); + }); +}); + +// Picking the tip's button should cause the Urlbar to blank out and the tip to +// be not to be shown again in any session. Telemetry should be updated. +add_task(async function pickButton_onboard() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + + Services.telemetry.clearEvents(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, false); + + // Click the tip button. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + gURLBar.blur(); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ), + MAX_SHOWN_COUNT, + "Onboarding tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Picking the tip's button should cause the Urlbar to blank out and the tip to +// be not to be shown again in any session. Telemetry should be updated. +add_task(async function pickButton_redirect() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Google"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withDNSRedirect("www.google.com", "/", async url => { + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT, false); + + // Click the tip button. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + gURLBar.blur(); + }); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}` + ), + MAX_SHOWN_COUNT, + "Redirect tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +// Picking the tip's button should cause the Urlbar to keep its current +// value and the tip to be not to be shown again in any session. +// Telemetry should be updated. +add_task(async function pickButton_persist() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.eventTelemetry.enabled", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Example"); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + SEARCH_SERP_URL + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, SEARCH_SERP_URL); + await browserLoadedPromise; + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.PERSIST, false); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let button = result.element.row._buttons.get("0"); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + gURLBar.blur(); + + Assert.equal( + gURLBar.value, + SEARCH_TERM, + "The Urlbar should keep its existing value." + ); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ), + MAX_SHOWN_COUNT, + "Persist tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +// Clicking in the input while the onboard tip is showing should have the same +// effect as picking the tip. +add_task(async function clickInInput_onboard() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Google"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, false); + + // Click in the input. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox.parentNode, {}); + }); + gURLBar.blur(); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ), + MAX_SHOWN_COUNT, + "Onboarding tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Pressing Ctrl+L (the open location command) while the onboard tip is showing +// should have the same effect as picking the tip. +add_task(async function openLocation_onboard() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Google"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, false); + + // Trigger the open location command. + await UrlbarTestUtils.promisePopupClose(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + gURLBar.blur(); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ), + MAX_SHOWN_COUNT, + "Onboarding tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Clicking in the input while the redirect tip is showing should have the same +// effect as picking the tip. +add_task(async function clickInInput_redirect() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Google"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withDNSRedirect("www.google.com", "/", async url => { + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT, false); + + // Click in the input. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox.parentNode, {}); + }); + gURLBar.blur(); + }); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}` + ), + MAX_SHOWN_COUNT, + "Redirect tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +// Clicking in the input while the persist tip is showing should have the same +// effect as picking the tip. +add_task(async function clickInInput_persist() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.eventTelemetry.enabled", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Example"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + SEARCH_SERP_URL + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, SEARCH_SERP_URL); + await browserLoadedPromise; + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.PERSIST, false); + + // Click in the input. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox.parentNode, {}); + }); + gURLBar.blur(); + Assert.equal( + gURLBar.value, + SEARCH_TERM, + "The Urlbar should keep its existing value." + ); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ), + MAX_SHOWN_COUNT, + "Persist tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +// Pressing Ctrl+L (the open location command) while the redirect tip is showing +// should have the same effect as picking the tip. +add_task(async function openLocation_redirect() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Google"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withDNSRedirect("www.google.com", "/", async url => { + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT, false); + + // Trigger the open location command. + await UrlbarTestUtils.promisePopupClose(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + gURLBar.blur(); + }); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}` + ), + MAX_SHOWN_COUNT, + "Redirect tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +// Pressing Ctrl+L (the open location command) while the persist tip is showing +// should have the same effect as picking the tip. +add_task(async function openLocation_persist() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.eventTelemetry.enabled", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + Services.telemetry.clearEvents(); + + await setDefaultEngine("Example"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + SEARCH_SERP_URL + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, SEARCH_SERP_URL); + await browserLoadedPromise; + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.PERSIST, false); + + // Trigger the open location command. + await UrlbarTestUtils.promisePopupClose(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + gURLBar.blur(); + Assert.equal( + gURLBar.value, + SEARCH_TERM, + "The Urlbar should keep its existing value." + ); + }); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}-picked`, + 1 + ); + TelemetryTestUtils.assertEvents( + [ + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ), + MAX_SHOWN_COUNT, + "Persist tips are disabled after tip button is picked." + ); + Assert.equal(gURLBar.value, "", "The Urlbar should be empty."); + resetSearchTipsProvider(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function pickingTipDoesNotDisableOtherKinds() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + await setDefaultEngine("Google"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, false); + + // Click the tip button. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + + gURLBar.blur(); + Assert.equal( + UrlbarPrefs.get( + `tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ), + MAX_SHOWN_COUNT, + "Onboarding tips are disabled after tip button is picked." + ); + + BrowserTestUtils.removeTab(tab); + + // Simulate a new session. + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + + // Onboarding tips should no longer be shown. + let tab2 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.NONE); + + // We should still show redirect tips. + await withDNSRedirect("www.google.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); + + BrowserTestUtils.removeTab(tab2); + resetSearchTipsProvider(); +}); + +// The tip shouldn't be shown when there's another notification present. +add_task(async function notification() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let box = gBrowser.getNotificationBox(); + let note = box.appendNotification("urlbar-test", { + label: "Test", + priority: box.PRIORITY_INFO_HIGH, + }); + // Give it a big persistence so it doesn't go away on page load. + note.persistence = 100; + await withDNSRedirect("www.google.com", "/", async url => { + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.NONE); + box.removeNotification(note, true); + }); + }); + resetSearchTipsProvider(); +}); + +// The tip should be shown when switching to a tab where it should be shown. +add_task(async function tabSwitch() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:newtab"); + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + Services.telemetry.clearScalars(); + await BrowserTestUtils.switchTab(gBrowser, tab); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD); + BrowserTestUtils.removeTab(tab); + resetSearchTipsProvider(); +}); + +// The engagement event should be ended if the user ignores a tip. +// See bug 1610024. +add_task(async function ignoreEndsEngagement() { + await setDefaultEngine("Google"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withDNSRedirect("www.google.com", "/", async url => { + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT, false); + // We're just looking for any target outside the Urlbar. + let spring = gURLBar.inputField + .closest("#nav-bar") + .querySelector("toolbarspring"); + await UrlbarTestUtils.promisePopupClose(window, async () => { + await EventUtils.synthesizeMouseAtCenter(spring, {}); + }); + Assert.ok( + UrlbarProviderSearchTips.showedTipTypeInCurrentEngagement == + UrlbarProviderSearchTips.TIP_TYPE.NONE, + "The engagement should have ended after the tip was ignored." + ); + }); + }); + resetSearchTipsProvider(); +}); + +add_task(async function pasteAndGo_url() { + await doPasteAndGoTest("http://example.com/", "http://example.com/"); +}); + +add_task(async function pasteAndGo_nonURL() { + await setDefaultEngine("Example"); + await doPasteAndGoTest( + "pasteAndGo_nonURL", + "https://example.com/?q=pasteAndGo_nonURL" + ); + await setDefaultEngine("Google"); +}); + +async function doPasteAndGoTest(searchString, expectedURL) { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "about:newtab", + waitForLoad: false, + }); + await checkTip(window, UrlbarProviderSearchTips.TIP_TYPE.ONBOARD, false); + + await SimpleTest.promiseClipboardChange(searchString, () => { + clipboardHelper.copyString(searchString); + }); + + let textBox = gURLBar.querySelector("moz-input-box"); + let cxmenu = textBox.menupopup; + let cxmenuPromise = BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { + type: "contextmenu", + button: 2, + }); + await cxmenuPromise; + let menuitem = textBox.getMenuItem("paste-and-go"); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedURL + ); + cxmenu.activateItem(menuitem); + await browserLoadedPromise; + BrowserTestUtils.removeTab(tab); + resetSearchTipsProvider(); +} + +// Since we coupled the logic that decides whether to show the tip with our +// gURLBar.search call, we should make sure search isn't called when +// the conditions for a tip are met but the provider is disabled. +add_task(async function noActionWhenDisabled() { + await setDefaultEngine("Bing"); + await withDNSRedirect("www.bing.com", "/", async url => { + await checkTab(window, url, UrlbarProviderSearchTips.TIP_TYPE.REDIRECT); + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features", + false, + ], + ], + }); + + await withDNSRedirect("www.bing.com", "/", async url => { + Assert.ok( + !UrlbarTestUtils.isPopupOpen(window), + "The UrlbarView should not be open." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_selection.js b/browser/components/urlbar/tests/browser-tips/browser_selection.js new file mode 100644 index 0000000000..89206b123e --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_selection.js @@ -0,0 +1,269 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests keyboard selection within UrlbarUtils.RESULT_TYPE.TIP results. + +"use strict"; + +const HELP_URL = "about:mozilla"; +const TIP_URL = "about:about"; + +add_task(async function tipIsSecondResult() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + makeTipResult({ buttonUrl: TIP_URL, helpUrl: HELP_URL }), + ]; + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results in the view." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.TIP, + "The second result should be a tip." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The first element should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-button-0" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + UrlbarPrefs.get("resultMenu") ? 2 : 1, + "Selected element index" + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + UrlbarPrefs.get("resultMenu") + ? "urlbarView-button-menu" + : "urlbarView-button-help" + ), + UrlbarPrefs.get("resultMenu") + ? "The selected element should be the tip menu button." + : "The selected element should be the tip help button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + UrlbarPrefs.get("resultMenu") + ? "getSelectedRowIndex should return 1 even though the menu button is selected." + : "getSelectedRowIndex should return 1 even though the help button is selected." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + UrlbarPrefs.get("resultMenu") ? 3 : 2, + "Selected element index" + ); + + // If this test is running alone, the one-offs will rebuild themselves when + // the view is opened above, and they may not be visible yet. Wait for the + // first one to become visible before trying to select it. + await TestUtils.waitForCondition(() => { + return ( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild && + BrowserTestUtils.is_visible( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild + ) + ); + }, "Waiting for first one-off to become visible."); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition(() => { + return gURLBar.view.oneOffSearchButtons.selectedButton; + }, "Waiting for one-off to become selected."); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + -1, + "No results should be selected." + ); + + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + UrlbarPrefs.get("resultMenu") + ? "urlbarView-button-menu" + : "urlbarView-button-help" + ), + UrlbarPrefs.get("resultMenu") + ? "The selected element should be the tip menu button." + : "The selected element should be the tip help button." + ); + + gURLBar.view.close(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function tipIsOnlyResult() { + let results = [makeTipResult({ buttonUrl: TIP_URL, helpUrl: HELP_URL })]; + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result in the view." + ); + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.TIP, + "The first and only result should be a tip." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-button-0" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The first element should be selected." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + UrlbarPrefs.get("resultMenu") + ? "urlbarView-button-menu" + : "urlbarView-button-help" + ), + UrlbarPrefs.get("resultMenu") + ? "The selected element should be the tip menu button." + : "The selected element should be the tip help button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 1, + "The second element should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + -1, + "There should be no selection." + ); + + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + UrlbarPrefs.get("resultMenu") + ? "urlbarView-button-menu" + : "urlbarView-button-help" + ), + UrlbarPrefs.get("resultMenu") + ? "The selected element should be the tip menu button." + : "The selected element should be the tip help button." + ); + + gURLBar.view.close(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function tipHasNoHelpButton() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + makeTipResult({ buttonUrl: TIP_URL }), + ]; + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results in the view." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.TIP, + "The second result should be a tip." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The first element should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-button-0" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + UrlbarPrefs.get("resultMenu") ? 2 : 1, + "Selected element index" + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition(() => { + return gURLBar.view.oneOffSearchButtons.selectedButton; + }, "Waiting for one-off to become selected."); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + -1, + "No results should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-button-0" + ), + "The selected element should be the tip button." + ); + + gURLBar.view.close(); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_updateAsk.js b/browser/components/urlbar/tests/browser-tips/browser_updateAsk.js new file mode 100644 index 0000000000..3f2014d8c0 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateAsk.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks the UPDATE_ASK tip. +// +// The update parts of this test are adapted from: +// https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_downloadOptIn.js + +"use strict"; + +let params = { queryString: "&invalidCompleteSize=1" }; + +let downloadInfo = []; +if (Services.prefs.getBoolPref(PREF_APP_UPDATE_BITS_ENABLED, false)) { + downloadInfo[0] = { patchType: "partial", bitsResult: "0" }; +} else { + downloadInfo[0] = { patchType: "partial", internalResult: "0" }; +} + +let preSteps = [ + { + panelId: "checkingForUpdates", + checkActiveUpdate: null, + continueFile: CONTINUE_CHECK, + }, + { + panelId: "downloadAndInstall", + checkActiveUpdate: null, + continueFile: null, + }, +]; + +let postSteps = [ + { + panelId: "downloading", + checkActiveUpdate: { state: STATE_DOWNLOADING }, + continueFile: CONTINUE_DOWNLOAD, + downloadInfo, + }, + { + panelId: "apply", + checkActiveUpdate: { state: STATE_PENDING }, + continueFile: null, + }, +]; + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Disable the pref that automatically downloads and installs updates. + await UpdateUtils.setAppUpdateAutoEnabled(false); + + // Set up the "download and install" update state. + await initUpdate(params); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps(preSteps); + + // Pick the tip and continue with the mock update, which should attempt to + // restart the browser. + await doUpdateTest({ + searchString: SEARCH_STRINGS.UPDATE, + tip: UrlbarProviderInterventions.TIP_TYPE.UPDATE_ASK, + title: /^A new version of .+ is available\.$/, + button: "Install and Restart to Update", + awaitCallback() { + return Promise.all([ + processUpdateSteps(postSteps), + awaitAppRestartRequest(), + ]); + }, + }); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_updateRefresh.js b/browser/components/urlbar/tests/browser-tips/browser_updateRefresh.js new file mode 100644 index 0000000000..5e94298996 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateRefresh.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks the UPDATE_REFRESH tip. +// +// The update parts of this test are adapted from: +// https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_noUpdate.js + +"use strict"; + +let params = { queryString: "&noUpdates=1" }; + +let preSteps = [ + { + panelId: "checkingForUpdates", + checkActiveUpdate: null, + continueFile: CONTINUE_CHECK, + }, + { + panelId: "noUpdatesFound", + checkActiveUpdate: null, + continueFile: null, + }, +]; + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + makeProfileResettable(); + + // Set up the "no updates" update state. + await initUpdate(params); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps(preSteps); + + // Picking the tip should open the refresh dialog. Click its cancel + // button. + await doUpdateTest({ + searchString: SEARCH_STRINGS.UPDATE, + tip: UrlbarProviderInterventions.TIP_TYPE.UPDATE_REFRESH, + title: + /^.+ is up to date\. Trying to fix a problem\? Restore default settings and remove old add-ons for optimal performance\.$/, + button: /^Refresh .+…$/, + awaitCallback() { + return BrowserTestUtils.promiseAlertDialog( + "cancel", + "chrome://global/content/resetProfile.xhtml", + { isSubDialog: true } + ); + }, + }); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_updateRestart.js b/browser/components/urlbar/tests/browser-tips/browser_updateRestart.js new file mode 100644 index 0000000000..75e92910f0 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateRestart.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks the UPDATE_RESTART tip. +// +// The update parts of this test are adapted from: +// https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/browser_aboutDialog_bc_downloaded_staged.js + +"use strict"; + +let params = { + queryString: "&invalidCompleteSize=1", + backgroundUpdate: true, + continueFile: CONTINUE_STAGING, + waitForUpdateState: STATE_APPLIED, +}; + +let preSteps = [ + { + panelId: "apply", + checkActiveUpdate: { state: STATE_APPLIED }, + continueFile: null, + }, +]; + +add_task(async function test() { + // Enable the pref that automatically downloads and installs updates. + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_APP_UPDATE_STAGING_ENABLED, true], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + // Set up the "apply" update state. + await initUpdate(params); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps(preSteps); + + // Picking the tip should attempt to restart the browser. + await doUpdateTest({ + searchString: SEARCH_STRINGS.UPDATE, + tip: UrlbarProviderInterventions.TIP_TYPE.UPDATE_RESTART, + title: /^The latest .+ is downloaded and ready to install\.$/, + button: "Restart to Update", + awaitCallback: awaitAppRestartRequest, + }); +}); diff --git a/browser/components/urlbar/tests/browser-tips/browser_updateWeb.js b/browser/components/urlbar/tests/browser-tips/browser_updateWeb.js new file mode 100644 index 0000000000..daca12fea4 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateWeb.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks the UPDATE_WEB tip. +// +// The update parts of this test are adapted from: +// https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/browser_aboutDialog_fc_check_unsupported.js + +"use strict"; + +let params = { queryString: "&unsupported=1" }; + +let preSteps = [ + { + panelId: "checkingForUpdates", + checkActiveUpdate: null, + continueFile: CONTINUE_CHECK, + }, + { + panelId: "unsupportedSystem", + checkActiveUpdate: null, + continueFile: null, + }, +]; + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Set up the "unsupported update" update state. + await initUpdate(params); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps(preSteps); + + // Picking the tip should open the download page in a new tab. + let downloadTab = await doUpdateTest({ + searchString: SEARCH_STRINGS.UPDATE, + tip: UrlbarProviderInterventions.TIP_TYPE.UPDATE_WEB, + title: /^Get the latest .+ browser\.$/, + button: "Download Now", + awaitCallback() { + return BrowserTestUtils.waitForNewTab( + gBrowser, + "https://www.mozilla.org/firefox/new/" + ); + }, + }); + + Assert.equal(gBrowser.selectedTab, downloadTab); + BrowserTestUtils.removeTab(downloadTab); +}); diff --git a/browser/components/urlbar/tests/browser-tips/head.js b/browser/components/urlbar/tests/browser-tips/head.js new file mode 100644 index 0000000000..d6797f5338 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/head.js @@ -0,0 +1,771 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This directory contains tests that check tips and interventions, and in +// particular the update-related interventions. +// We mock updates by using the test helpers in +// toolkit/mozapps/update/tests/browser. + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/head.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +XPCOMUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "SearchTestUtils", () => { + const { SearchTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +// For each intervention type, a search string that trigger the intervention. +const SEARCH_STRINGS = { + CLEAR: "firefox history", + REFRESH: "firefox slow", + UPDATE: "firefox update", +}; + +registerCleanupFunction(() => { + // We need to reset the provider's appUpdater.status between tests so that + // each test doesn't interfere with the next. + UrlbarProviderInterventions.resetAppUpdater(); +}); + +/** + * Override our binary path so that the update lock doesn't think more than one + * instance of this test is running. + * This is a heavily pared down copy of the function in xpcshellUtilsAUS.js. + */ +function adjustGeneralPaths() { + let dirProvider = { + getFile(aProp, aPersistent) { + // Set the value of persistent to false so when this directory provider is + // unregistered it will revert back to the original provider. + aPersistent.value = false; + // The sync manager only uses XRE_EXECUTABLE_FILE, so that's all we need + // to override, we won't bother handling anything else. + if (aProp == XRE_EXECUTABLE_FILE) { + // The temp directory that the mochitest runner creates is unique per + // test, so its path can serve to provide the unique key that the update + // sync manager requires (it doesn't need for this to be the actual + // path to any real file, it's only used as an opaque string). + let tempPath = Services.env.get("MOZ_PROCESS_LOG"); + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(tempPath); + return file; + } + return null; + }, + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + + let ds = Services.dirsvc.QueryInterface(Ci.nsIDirectoryService); + try { + ds.QueryInterface(Ci.nsIProperties).undefine(XRE_EXECUTABLE_FILE); + } catch (_ex) { + // We only override one property, so we have nothing to do if that fails. + return; + } + ds.registerProvider(dirProvider); + registerCleanupFunction(() => { + ds.unregisterProvider(dirProvider); + // Reset the update lock once again so that we know the lock we're + // interested in here will be closed properly (normally that happens during + // XPCOM shutdown, but that isn't consistent during tests). + let syncManager = Cc[ + "@mozilla.org/updates/update-sync-manager;1" + ].getService(Ci.nsIUpdateSyncManager); + syncManager.resetLock(); + }); + + // Now that we've overridden the directory provider, the name of the update + // lock needs to be changed to match the overridden path. + let syncManager = Cc["@mozilla.org/updates/update-sync-manager;1"].getService( + Ci.nsIUpdateSyncManager + ); + syncManager.resetLock(); +} + +/** + * Initializes a mock app update. Adapted from runAboutDialogUpdateTest: + * https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/head.js + * + * @param {object} params + * See the files in toolkit/mozapps/update/tests/browser. + */ +async function initUpdate(params) { + Services.env.set("MOZ_TEST_SLOW_SKIP_UPDATE_STAGE", "1"); + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_APP_UPDATE_DISABLEDFORTESTING, false], + [PREF_APP_UPDATE_URL_MANUAL, gDetailsURL], + ], + }); + + adjustGeneralPaths(); + await setupTestUpdater(); + + let queryString = params.queryString ? params.queryString : ""; + let updateURL = + URL_HTTP_UPDATE_SJS + + "?detailsURL=" + + gDetailsURL + + queryString + + getVersionParams(); + if (params.backgroundUpdate) { + setUpdateURL(updateURL); + gAUS.checkForBackgroundUpdates(); + if (params.continueFile) { + await continueFileHandler(params.continueFile); + } + if (params.waitForUpdateState) { + let whichUpdate = + params.waitForUpdateState == STATE_DOWNLOADING + ? "downloadingUpdate" + : "readyUpdate"; + await TestUtils.waitForCondition( + () => + gUpdateManager[whichUpdate] && + gUpdateManager[whichUpdate].state == params.waitForUpdateState, + "Waiting for update state: " + params.waitForUpdateState, + undefined, + 200 + ).catch(e => { + // Instead of throwing let the check below fail the test so the panel + // ID and the expected panel ID is printed in the log. + logTestInfo(e); + }); + // Display the UI after the update state equals the expected value. + Assert.equal( + gUpdateManager[whichUpdate].state, + params.waitForUpdateState, + "The update state value should equal " + params.waitForUpdateState + ); + } + } else { + updateURL += "&slowUpdateCheck=1&useSlowDownloadMar=1"; + setUpdateURL(updateURL); + } +} + +/** + * Performs steps in a mock update. Adapted from runAboutDialogUpdateTest: + * https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/head.js + * + * @param {Array} steps + * See the files in toolkit/mozapps/update/tests/browser. + */ +async function processUpdateSteps(steps) { + for (let step of steps) { + await processUpdateStep(step); + } +} + +/** + * Performs a step in a mock update. Adapted from runAboutDialogUpdateTest: + * https://searchfox.org/mozilla-central/source/toolkit/mozapps/update/tests/browser/head.js + * + * @param {object} step + * See the files in toolkit/mozapps/update/tests/browser. + */ +async function processUpdateStep(step) { + if (typeof step == "function") { + step(); + return; + } + + const { panelId, checkActiveUpdate, continueFile, downloadInfo } = step; + + if ( + panelId == "downloading" && + gAUS.currentState == Ci.nsIApplicationUpdateService.STATE_IDLE + ) { + // Now that `AUS.downloadUpdate` is async, we start showing the + // downloading panel while `AUS.downloadUpdate` is still resolving. + // But the below checks assume that this resolution has already + // happened. So we need to wait for things to actually resolve. + await gAUS.stateTransition; + } + + if (checkActiveUpdate) { + let whichUpdate = + checkActiveUpdate.state == STATE_DOWNLOADING + ? "downloadingUpdate" + : "readyUpdate"; + await TestUtils.waitForCondition( + () => gUpdateManager[whichUpdate], + "Waiting for active update" + ); + Assert.ok( + !!gUpdateManager[whichUpdate], + "There should be an active update" + ); + Assert.equal( + gUpdateManager[whichUpdate].state, + checkActiveUpdate.state, + "The active update state should equal " + checkActiveUpdate.state + ); + } else { + Assert.ok( + !gUpdateManager.readyUpdate, + "There should not be a ready update" + ); + Assert.ok( + !gUpdateManager.downloadingUpdate, + "There should not be a downloadingUpdate update" + ); + } + + if (panelId == "downloading") { + for (let i = 0; i < downloadInfo.length; ++i) { + let data = downloadInfo[i]; + // The About Dialog tests always specify a continue file. + await continueFileHandler(continueFile); + let patch = getPatchOfType( + data.patchType, + gUpdateManager.downloadingUpdate + ); + // The update is removed early when the last download fails so check + // that there is a patch before proceeding. + let isLastPatch = i == downloadInfo.length - 1; + if (!isLastPatch || patch) { + let resultName = data.bitsResult ? "bitsResult" : "internalResult"; + patch.QueryInterface(Ci.nsIWritablePropertyBag); + await TestUtils.waitForCondition( + () => patch.getProperty(resultName) == data[resultName], + "Waiting for expected patch property " + + resultName + + " value: " + + data[resultName], + undefined, + 200 + ).catch(e => { + // Instead of throwing let the check below fail the test so the + // property value and the expected property value is printed in + // the log. + logTestInfo(e); + }); + Assert.equal( + patch.getProperty(resultName), + data[resultName], + "The patch property " + + resultName + + " value should equal " + + data[resultName] + ); + } + } + } else if (continueFile) { + await continueFileHandler(continueFile); + } +} + +/** + * Checks an intervention tip. This works by starting a search that should + * trigger a tip, picks the tip, and waits for the tip's action to happen. + * + * @param {object} options + * Options for the test + * @param {string} options.searchString + * The search string. + * @param {string} options.tip + * The expected tip type. + * @param {string | RegExp} options.title + * The expected tip title. + * @param {string | RegExp} options.button + * The expected button title. + * @param {Function} options.awaitCallback + * A function that checks the tip's action. Should return a promise (or be + * async). + * @returns {object} + * The value returned from `awaitCallback`. + */ +async function doUpdateTest({ + searchString, + tip, + title, + button, + awaitCallback, +} = {}) { + // Do a search that triggers the tip. + let [result, element] = await awaitTip(searchString); + Assert.strictEqual(result.payload.type, tip, "Tip type"); + await element.ownerDocument.l10n.translateFragment(element); + + let actualTitle = element._elements.get("title").textContent; + if (typeof title == "string") { + Assert.equal(actualTitle, title, "Title string"); + } else { + // regexp + Assert.ok(title.test(actualTitle), "Title regexp"); + } + + let actualButton = element._buttons.get("0").textContent; + if (typeof button == "string") { + Assert.equal(actualButton, button, "Button string"); + } else { + // regexp + Assert.ok(button.test(actualButton), "Button regexp"); + } + + if (UrlbarPrefs.get("resultMenu")) { + Assert.ok(element._buttons.has("menu"), "Tip has a menu button"); + } else { + Assert.ok(element._buttons.has("help"), "Tip has a help button"); + } + + // Pick the tip and wait for the action. + let values = await Promise.all([awaitCallback(), pickTip()]); + + // Check telemetry. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${tip}-shown`, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${tip}-picked`, + 1 + ); + + return values[0] || null; +} + +/** + * Starts a search and asserts that the second result is a tip. + * + * @param {string} searchString + * The search string. + * @param {window} win + * The window. + * @returns {(result| element)[]} + * The result and its element in the DOM. + */ +async function awaitTip(searchString, win = window) { + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: searchString, + waitForFocus, + fireInputEvent: true, + }); + Assert.ok(context.results.length >= 2); + let result = context.results[1]; + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TIP); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 1); + return [result, element]; +} + +/** + * Picks the current tip's button. The view should be open and the second + * result should be a tip. + */ +async function pickTip() { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(button, {}); + }); +} + +/** + * Waits for the quit-application-requested notification and cancels it (so that + * the app isn't actually restarted). + */ +async function awaitAppRestartRequest() { + await TestUtils.topicObserved( + "quit-application-requested", + (cancelQuit, data) => { + if (data == "restart") { + cancelQuit.QueryInterface(Ci.nsISupportsPRBool).data = true; + return true; + } + return false; + } + ); +} + +/** + * Sets up the profile so that it can be reset. + */ +function makeProfileResettable() { + // Make reset possible. + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let currentProfileDir = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileName = "mochitest-test-profile-temp-" + Date.now(); + let tempProfile = profileService.createProfile( + currentProfileDir, + profileName + ); + Assert.ok( + ResetProfile.resetSupported(), + "Should be able to reset from mochitest's temporary profile once it's in the profile manager." + ); + + registerCleanupFunction(() => { + tempProfile.remove(false); + Assert.ok( + !ResetProfile.resetSupported(), + "Shouldn't be able to reset from mochitest's temporary profile once removed from the profile manager." + ); + }); +} + +/** + * Starts a search that should trigger a tip, picks the tip, and waits for the + * tip's action to happen. + * + * @param {object} options + * Options for the test + * @param {string} options.searchString + * The search string. + * @param {TIPS} options.tip + * The expected tip type. + * @param {string} options.title + * The expected tip title. + * @param {string} options.button + * The expected button title. + * @param {Function} options.awaitCallback + * A function that checks the tip's action. Should return a promise (or be + * async). + * @returns {*} + * The value returned from `awaitCallback`. + */ +function checkIntervention({ + searchString, + tip, + title, + button, + awaitCallback, +} = {}) { + // Opening modal dialogs confuses focus on Linux just after them, thus run + // these checks in separate tabs to better isolate them. + return BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search that triggers the tip. + let [result, element] = await awaitTip(searchString); + Assert.strictEqual(result.payload.type, tip); + await element.ownerDocument.l10n.translateFragment(element); + + let actualTitle = element._elements.get("title").textContent; + if (typeof title == "string") { + Assert.equal(actualTitle, title, "Title string"); + } else { + // regexp + Assert.ok(title.test(actualTitle), "Title regexp"); + } + + let actualButton = element._buttons.get("0").textContent; + if (typeof button == "string") { + Assert.equal(actualButton, button, "Button string"); + } else { + // regexp + Assert.ok(button.test(actualButton), "Button regexp"); + } + + if (UrlbarPrefs.get("resultMenu")) { + let menuButton = element._buttons.get("menu"); + Assert.ok(menuButton, "Menu button exists"); + Assert.ok( + BrowserTestUtils.is_visible(menuButton), + "Menu button is visible" + ); + } else { + let helpButton = element._buttons.get("help"); + Assert.ok(helpButton, "Help button exists"); + Assert.ok( + BrowserTestUtils.is_visible(helpButton), + "Help button is visible" + ); + } + + let values = await Promise.all([awaitCallback(), pickTip()]); + Assert.ok(true, "Refresh dialog opened"); + + // Ensure the urlbar is closed so that the engagement is ended. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${tip}-shown`, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${tip}-picked`, + 1 + ); + + return values[0] || null; + }); +} + +/** + * Starts a search and asserts that there are no tips. + * + * @param {string} searchString + * The search string. + * @param {Window} win + * The host window. + */ +async function awaitNoTip(searchString, win = window) { + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: searchString, + waitForFocus, + fireInputEvent: true, + }); + for (let result of context.results) { + Assert.notEqual(result.type, UrlbarUtils.RESULT_TYPE.TIP); + } +} + +/** + * Search tips helper. Asserts that a particular search tip is shown or that no + * search tip is shown. + * + * @param {window} win + * A browser window. + * @param {UrlbarProviderSearchTips.TIP_TYPE} expectedTip + * The expected search tip. Pass a falsey value (like zero) for none. + * @param {boolean} closeView + * If true, this function closes the urlbar view before returning. + */ +async function checkTip(win, expectedTip, closeView = true) { + if (!expectedTip) { + // Wait a bit for the tip to not show up. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + Assert.ok(!win.gURLBar.view.isOpen); + return; + } + + // Wait for the view to open, and then check the tip result. + await UrlbarTestUtils.promisePopupOpen(win, () => {}); + Assert.ok(true, "View opened"); + Assert.equal(UrlbarTestUtils.getResultCount(win), 1); + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TIP); + let heuristic; + let title; + let name = Services.search.defaultEngine.name; + switch (expectedTip) { + case UrlbarProviderSearchTips.TIP_TYPE.ONBOARD: + heuristic = true; + title = + `Type less, find more: Search ${name} right from your ` + + `address bar.`; + break; + case UrlbarProviderSearchTips.TIP_TYPE.REDIRECT: + heuristic = false; + title = + `Start your search in the address bar to see suggestions from ` + + `${name} and your browsing history.`; + break; + case UrlbarProviderSearchTips.TIP_TYPE.PERSIST: + heuristic = false; + title = + "Searching just got simpler." + + " Try making your search more specific here in the address bar." + + " To show the URL instead, visit Search, in settings."; + break; + } + Assert.equal(result.heuristic, heuristic); + Assert.equal(result.displayed.title, title); + Assert.equal( + result.element.row._buttons.get("0").textContent, + expectedTip == UrlbarProviderSearchTips.TIP_TYPE.PERSIST + ? `Got it` + : `Okay, Got It` + ); + Assert.ok(!result.element.row._buttons.has("help")); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + `${expectedTip}-shown`, + 1 + ); + + Assert.ok( + !UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + "One-offs should be hidden when showing a search tip" + ); + + if (closeView) { + await UrlbarTestUtils.promisePopupClose(win); + } +} + +function makeTipResult({ buttonUrl, helpUrl = undefined }) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl, + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: buttonUrl, + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ); +} + +/** + * Search tips helper. Opens a foreground tab and asserts that a particular + * search tip is shown or that no search tip is shown. + * + * @param {window} win + * A browser window. + * @param {string} url + * The URL to load in a new foreground tab. + * @param {UrlbarProviderSearchTips.TIP_TYPE} expectedTip + * The expected search tip. Pass a falsey value (like zero) for none. + * @param {boolean} reset + * If true, the search tips provider will be reset before this function + * returns. See resetSearchTipsProvider. + */ +async function checkTab(win, url, expectedTip, reset = true) { + // BrowserTestUtils.withNewTab always waits for tab load, which hangs on + // about:newtab for some reason, so don't use it. + let shownCount; + if (expectedTip) { + shownCount = UrlbarPrefs.get(`tipShownCount.${expectedTip}`); + } + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + url, + waitForLoad: url != "about:newtab", + }); + + await checkTip(win, expectedTip, true); + if (expectedTip) { + Assert.equal( + UrlbarPrefs.get(`tipShownCount.${expectedTip}`), + shownCount + 1, + "The shownCount pref should have been incremented by one." + ); + } + + if (reset) { + resetSearchTipsProvider(); + } + + BrowserTestUtils.removeTab(tab); +} + +/** + * This lets us visit www.google.com (for example) and have it redirect to + * our test HTTP server instead of visiting the actual site. + * + * @param {string} domain + * The domain to which we are redirecting. + * @param {string} path + * The pathname on the domain. + * @param {Function} callback + * Executed when the test suite thinks `domain` is loaded. + */ +async function withDNSRedirect(domain, path, callback) { + // Some domains have special security requirements, like www.bing.com. We + // need to override them to successfully load them. This part is adapted from + // testing/marionette/cert.js. + const certOverrideService = Cc[ + "@mozilla.org/security/certoverride;1" + ].getService(Ci.nsICertOverrideService); + Services.prefs.setBoolPref( + "network.stricttransportsecurity.preloadlist", + false + ); + Services.prefs.setIntPref("security.cert_pinning.enforcement_level", 0); + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + true + ); + + // Now set network.dns.localDomains to redirect the domain to localhost and + // set up an HTTP server. + Services.prefs.setCharPref("network.dns.localDomains", domain); + + let server = new HttpServer(); + server.registerPathHandler(path, (req, resp) => { + resp.write(`Test! http://${domain}${path}`); + }); + server.start(-1); + server.identity.setPrimary("http", domain, server.identity.primaryPort); + let url = `http://${domain}:${server.identity.primaryPort}${path}`; + + await callback(url); + + // Reset network.dns.localDomains and stop the server. + Services.prefs.clearUserPref("network.dns.localDomains"); + await new Promise(resolve => server.stop(resolve)); + + // Reset the security stuff. + certOverrideService.setDisableAllSecurityChecksAndLetAttackersInterceptMyData( + false + ); + Services.prefs.clearUserPref("network.stricttransportsecurity.preloadlist"); + Services.prefs.clearUserPref("security.cert_pinning.enforcement_level"); + const sss = Cc["@mozilla.org/ssservice;1"].getService( + Ci.nsISiteSecurityService + ); + sss.clearAll(); +} + +function resetSearchTipsProvider() { + Services.prefs.clearUserPref( + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ); + Services.prefs.clearUserPref( + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}` + ); + Services.prefs.clearUserPref( + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.REDIRECT}` + ); + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; +} + +async function setDefaultEngine(name) { + let engine = (await Services.search.getEngines()).find(e => e.name == name); + Assert.ok(engine); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +} diff --git a/browser/components/urlbar/tests/browser-tips/slow-page.html b/browser/components/urlbar/tests/browser-tips/slow-page.html new file mode 100644 index 0000000000..f58a44dc62 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/slow-page.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <body> + <h1>Search Engine Results Page that is loading a slow resource.</h1> + </body> + <script src="https://www.example.com/browser/browser/components/urlbar/tests/browser-tips/slow-page.sjs"></script> +</html> diff --git a/browser/components/urlbar/tests/browser-updateResults/browser.ini b/browser/components/urlbar/tests/browser-updateResults/browser.ini new file mode 100644 index 0000000000..f20fed7e13 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser.ini @@ -0,0 +1,17 @@ +# 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/. + +[DEFAULT] +support-files = + head.js + +[browser_appendSpanCount.js] +[browser_suggestedIndex_10_search_10_url.js] +[browser_suggestedIndex_10_search_5_url.js] +[browser_suggestedIndex_10_url_10_search.js] +[browser_suggestedIndex_10_url_5_search.js] +[browser_suggestedIndex_5_search_10_url.js] +[browser_suggestedIndex_5_search_5_url.js] +[browser_suggestedIndex_5_url_10_search.js] +[browser_suggestedIndex_5_url_5_search.js] diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_appendSpanCount.js b/browser/components/urlbar/tests/browser-updateResults/browser_appendSpanCount.js new file mode 100644 index 0000000000..bcf9609597 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_appendSpanCount.js @@ -0,0 +1,183 @@ +/* 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/. */ + +// This test makes sure that when the view updates itself and appends new rows, +// the new rows start out hidden when they exceed the current visible result +// span count. It includes a tip result so that it tests a row with > 1 result +// span. + +"use strict"; + +add_task(async function viewUpdateAppendHidden() { + // We'll use this test provider to test specific results. We assume that + // history and bookmarks have been cleared (by init() above). + let provider = new DelayingTestProvider(); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(provider); + }); + + // We do two searches below without closing the panel. Use "firefox cach" as + // the first query and "firefox cache" as the second so that (1) an + // intervention tip is triggered both times but also so that (2) the queries + // are different each time. + let baseQuery = "firefox cache"; + let queries = [baseQuery.substring(0, baseQuery.length - 1), baseQuery]; + let maxResults = UrlbarPrefs.get("maxRichResults"); + + let queryStrings = []; + for (let i = 0; i < maxResults; i++) { + queryStrings.push(`${baseQuery} ${i}`); + } + + // First search: Trigger the intervention tip and a view full of search + // suggestions. + provider._results = queryStrings.map( + suggestion => + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + query: queries[0], + suggestion, + lowerCaseSuggestion: suggestion.toLocaleLowerCase(), + engine: Services.search.defaultEngine.name, + } + ) + ); + provider.finishQueryPromise = Promise.resolve(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: queries[0], + }); + + // Sanity check the tip result and row count. + let tipResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + tipResult.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Result at index 1 is a tip" + ); + let tipResultSpan = UrlbarUtils.getSpanForResult( + tipResult.element.row.result + ); + Assert.greater(tipResultSpan, 1, "Sanity check: Tip has large result span"); + let expectedRowCount = maxResults - tipResultSpan + 1; + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedRowCount, + "Sanity check: Initial row count takes tip result span into account" + ); + + // Second search: Change the provider's results so that it has enough history + // to fill up the view. Search suggestion rows cannot be updated to history + // results, so the view will append the history results as new rows. + provider._results = queryStrings.map(title => { + let url = "http://example.com/" + title; + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + title, + url, + displayUrl: "http://example.com/" + title, + } + ); + }); + + // Don't allow the search to finish until we check the updated rows. We'll + // accomplish that by adding a mutation observer on the rows and delaying + // resolving the provider's finishQueryPromise. When all new rows have been + // added, we expect the new row count to be: + // + // expectedRowCount // the original row count + // + (expectedRowCount - 2) // the newly added history row count (hidden) + // -------------------------- + // (2 * expectedRowCount) - 2 + // + // The `- 2` subtracts the heuristic and tip result. + let newExpectedRowCount = 2 * expectedRowCount - 2; + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + let childCount = UrlbarTestUtils.getResultCount(window); + info(`Rows mutation observer called, childCount now ${childCount}`); + if (newExpectedRowCount <= childCount) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + childList: true, + }); + }); + + // Now do the second search but don't wait for it to finish. + let resolveQuery; + provider.finishQueryPromise = new Promise( + resolve => (resolveQuery = resolve) + ); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: queries[1], + }); + + // Wait for the history rows to be added. + await mutationPromise; + + // Check the rows. We can't use UrlbarTestUtils.getDetailsOfResultAt() here + // because it waits for the query to finish. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + newExpectedRowCount, + "New expected row count" + ); + // stale search rows + let rows = UrlbarTestUtils.getResultsContainer(window).children; + for (let i = 2; i < expectedRowCount; i++) { + let row = rows[i]; + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + `Result at index ${i} is a search result` + ); + Assert.ok( + BrowserTestUtils.is_visible(row), + `Search result at index ${i} is visible` + ); + Assert.equal( + row.getAttribute("stale"), + "true", + `Search result at index ${i} is stale` + ); + } + // new hidden history rows + for (let i = expectedRowCount; i < newExpectedRowCount; i++) { + let row = rows[i]; + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.URL, + `Result at index ${i} is a URL result` + ); + Assert.ok( + !BrowserTestUtils.is_visible(row), + `URL result at index ${i} is hidden` + ); + Assert.ok( + !row.hasAttribute("stale"), + `URL result at index ${i} is not stale` + ); + } + + // Finish the query, and we're done. + resolveQuery(); + await queryPromise; + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + + // We unregister the provider above in a cleanup function so we don't + // accidentally interfere with later tests, but do it here too in case we add + // more tasks to this test. It's harmless to call more than once. + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_10_url.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_10_url.js new file mode 100644 index 0000000000..755c453850 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_10_url.js @@ -0,0 +1,1102 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 10 results with search suggestions, and search 2 returns 10 results +// with URL results. + +"use strict"; + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes (because the original search results can't +// be replaced with URL results) +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 9 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2]], + viewCount: 9, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_5_url.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_5_url.js new file mode 100644 index 0000000000..bd707dd422 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_search_5_url.js @@ -0,0 +1,661 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 10 results with search suggestions, and search 2 returns 5 results +// with URL results. + +"use strict"; + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 5 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 9 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2]], + viewCount: 9, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [[1, 2], -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_10_search.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_10_search.js new file mode 100644 index 0000000000..57f172adba --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_10_search.js @@ -0,0 +1,1185 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 10 results where the first result is a search suggestion and the +// remainder are URL results, and search 2 returns 10 results with search +// suggestions. This tests the view-update logic that allows search suggestions +// to replace other results once an existing suggestion row is encountered. + +"use strict"; + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// Indexes 2-7 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 7, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 7, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 7, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -9 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -9, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 7, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + resultSpan: 2, + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2-8 replaced with search suggestions +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndexes: [[1, 2]], + viewCount: 9, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_5_search.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_5_search.js new file mode 100644 index 0000000000..03d6f158f4 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_10_url_5_search.js @@ -0,0 +1,707 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 10 results where the first result is a search suggestion and the +// remainder are URL results, and search 2 returns 5 results with search +// suggestions. This tests the view-update logic that allows search suggestions +// to replace other results once an existing suggestion row is encountered. + +"use strict"; + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// Indexes 1 and 2 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// Indexes 3 and 4 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// Index 3 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 10 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 10 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 10, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 5 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// Index 3 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 9, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndexes: [[1, 2]], + viewCount: 9, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2], -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_10_url.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_10_url.js new file mode 100644 index 0000000000..dfd626d701 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_10_url.js @@ -0,0 +1,1015 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 5 results with search suggestions, and search 2 returns 10 results +// with URL results. + +"use strict"; + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 search-1 rows + 1 search-2 row (the one before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 4 +// Expected visible rows during update: +// 5 search-1 rows + 3 search-2 rows (the ones before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 4, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 4, + hidden: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 6 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows (the ones before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 6, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 6, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 8 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows (some of the ones before the +// suggestedIndex) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 8, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 8, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows (some of the ones before the +// suggestedIndex) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -4 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -4, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -4, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -6 +// Expected visible rows during update: +// 5 search-1 rows + 3 search-2 rows (the ones before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -6, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -6, + hidden: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -8 +// Expected visible rows during update: +// 5 search-1 rows + 1 search-2 row (the one before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -8, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -8, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 3 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 3, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 3, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -7 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -7, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -7, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 search-1 rows + 5 search-2 rows (some of the ones before the +// suggestedIndex) +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 5 search-1 rows + 4 search-2 rows +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2]], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.URL }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_5_url.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_5_url.js new file mode 100644 index 0000000000..49d6ac0663 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_search_5_url.js @@ -0,0 +1,1131 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 5 results with search suggestions, and search 2 returns 5 results +// with URL results. + +"use strict"; + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 search-1 rows + 1 search-2 row (the one before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 search-1 rows + 3 search-2 rows (the ones before the suggestedIndex) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 search-1 rows + 3 search-2 rows (the ones before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 search-1 rows + 2 search-2 rows (the one before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 search-1 rows + 3 search-2 rows (i.e., all rows from both searches) +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes (because the original search results can't +// be replaced with URL results) +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes (because the original search results can't +// be replaced with URL results) +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 4, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 4, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 search-1 rows + 2 search-2 rows (the ones before the suggestedIndex row) +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_10_search.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_10_search.js new file mode 100644 index 0000000000..4306efbeff --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_10_search.js @@ -0,0 +1,1057 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 5 results where the first result is a search suggestion and the +// remainder are URL results, and search 2 returns 10 results with search +// suggestions. This tests the view-update logic that allows search suggestions +// to replace other results once an existing suggestion row is encountered. + +"use strict"; + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 4 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 4, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 4, + hidden: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 6 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 6, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 6, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 8 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 8, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 8, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -4 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -4, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -4, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -6 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -6, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -6, + hidden: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = -8 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -8, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -8, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = -9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -9, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 8, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 3 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 3, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 3, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { count: 5, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 10 results including suggestedIndex = -7 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -7, + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -7, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 10 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 10, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 9 results including suggestedIndex = 1 with resultSpan = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + resultSpan: 2, + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 7, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + hidden: true, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 with resultSpan = 2 +// Search 2: +// 9 results including: +// suggestedIndex = 1 with resultSpan = 2 +// suggestedIndex = -1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndexes: [[1, 2]], + viewCount: 5, + }, + search2: { + otherCount: 10, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [[1, 2], -1], + viewCount: 9, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + resultSpan: 2, + }, + { count: 6, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_5_search.js b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_5_search.js new file mode 100644 index 0000000000..44078c2251 --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/browser_suggestedIndex_5_url_5_search.js @@ -0,0 +1,1203 @@ +/* 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/. */ + +// This test checks row visibility during view updates when rows with suggested +// indexes are added and removed. Each task performs two searches: Search 1 +// returns 5 results where the first result is a search suggestion and the +// remainder are URL results, and search 2 returns 5 results with search +// suggestions. This tests the view-update logic that allows search suggestions +// to replace other results once an existing suggestion row is encountered. + +"use strict"; + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 2, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 2, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 9 +// Search 2: +// 5 results including suggestedIndex = -3 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 9, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -3, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -3, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Indexes 2 and 3 replaced with search suggestions, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 2, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + hidden: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 2 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 2, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = 9 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: 9, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 9, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = -1 +// Expected visible rows during update: +// Index 2 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -1, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + stale: true, + }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -2 +// Search 2: +// 5 results including suggestedIndex = -2 +// Expected visible rows during update: +// All search-2 rows +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -2, + viewCount: 5, + }, + search2: { + otherCount: 3, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndex: -2, + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { + count: 1, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -2, + }, + { + count: 1, + type: UrlbarPrefs.get("resultMenu") + ? UrlbarUtils.RESULT_TYPE.URL + : UrlbarUtils.RESULT_TYPE.SEARCH, + }, + ], +}); + +// Search 1: +// 5 results, no suggestedIndex +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 3, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = 1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// Index 3 replaced with search suggestion, no other changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: 1, + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); + +// Search 1: +// 5 results including suggestedIndex = -1 +// Search 2: +// 5 results including suggestedIndex = 1 and suggestedIndex = -1 +// Expected visible rows during update: +// 5 original rows with no changes +add_suggestedIndex_task({ + search1: { + other: [ + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL }, + ], + suggestedIndex: -1, + viewCount: 5, + }, + search2: { + otherCount: 2, + otherType: UrlbarUtils.RESULT_TYPE.SEARCH, + suggestedIndexes: [1, -1], + viewCount: 5, + }, + duringUpdate: [ + { count: 1 }, + { count: 1, type: UrlbarUtils.RESULT_TYPE.SEARCH, stale: true }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.URL, stale: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + stale: true, + }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: 1, + hidden: true, + }, + { count: 2, type: UrlbarUtils.RESULT_TYPE.SEARCH, hidden: true }, + { + count: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + suggestedIndex: -1, + hidden: true, + }, + ], +}); diff --git a/browser/components/urlbar/tests/browser-updateResults/head.js b/browser/components/urlbar/tests/browser-updateResults/head.js new file mode 100644 index 0000000000..7bdb1bcb1f --- /dev/null +++ b/browser/components/urlbar/tests/browser-updateResults/head.js @@ -0,0 +1,550 @@ +/* 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/. */ + +// The files in this directory test UrlbarView.#updateResults(). + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +add_setup(async function headInit() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Make absolutely sure the panel stays open during the test. There are + // spurious blurs on WebRender TV tests as the test starts that cause the + // panel to close and the query to be canceled, resulting in intermittent + // failures without this. + ["ui.popup.disable_autohide", true], + + // Make sure maxRichResults is 10 for sanity. + ["browser.urlbar.maxRichResults", 10], + ], + }); + + // Increase the timeout of the remove-stale-rows timer so that it doesn't + // interfere with the tests. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + UrlbarView.removeStaleRowsTimeout = 30000; + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + }); +}); + +/** + * A test provider that doesn't finish startQuery() until `finishQueryPromise` + * is resolved. + */ +class DelayingTestProvider extends UrlbarTestUtils.TestProvider { + finishQueryPromise = null; + async startQuery(context, addCallback) { + for (let result of this._results) { + addCallback(this, result); + } + await this.finishQueryPromise; + } +} + +/** + * Makes a result with a suggested index. + * + * @param {number} suggestedIndex + * The preferred index of the result. + * @param {number} resultSpan + * The result will have this span. + * @returns {UrlbarResult} + */ +function makeSuggestedIndexResult(suggestedIndex, resultSpan = 1) { + return Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/si", + displayUrl: "http://example.com/si", + title: "suggested index", + } + ), + { suggestedIndex, resultSpan } + ); +} + +/** + * Makes an array of results for the suggestedIndex tests. The array will + * include a heuristic followed by the specified results. + * + * @param {object} options + * The options object + * @param {number} [options.count] + * The number of results to return other than the heuristic. This and + * `type` must be given together. + * @param {UrlbarUtils.RESULT_TYPE} [options.type] + * The type of results to return other than the heuristic. This and `count` + * must be given together. + * @param {Array} [options.specs] + * If you want a mix of result types instead of only one type, then use this + * param instead of `count` and `type`. Each item in this array must be an + * object with the following properties: + * {number} count + * The number of results to return for the given `type`. + * {UrlbarUtils.RESULT_TYPE} type + * The type of results. + * @returns {Array} + * An array of results. + */ +function makeProviderResults({ count = 0, type = undefined, specs = [] }) { + if (count) { + specs.push({ count, type }); + } + + let query = "test"; + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + query, + engine: Services.search.defaultEngine.name, + } + ), + { heuristic: true } + ), + ]; + + for (let { count: specCount, type: specType } of specs) { + for (let i = 0; i < specCount; i++) { + let str = `${query} ${results.length}`; + switch (specType) { + case UrlbarUtils.RESULT_TYPE.SEARCH: + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + query, + suggestion: str, + lowerCaseSuggestion: str.toLowerCase(), + engine: Services.search.defaultEngine.name, + } + ) + ); + break; + case UrlbarUtils.RESULT_TYPE.URL: + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/" + i, + displayUrl: "http://example.com/" + i, + title: str, + } + ) + ); + break; + default: + throw new Error(`Unsupported makeProviderResults type: ${specType}`); + } + } + } + + return results; +} + +let gSuggestedIndexTaskIndex = 0; + +/** + * Adds a suggestedIndex test task. See doSuggestedIndexTest() for params. + * + * @param {object} options + * See doSuggestedIndexTest(). + */ +function add_suggestedIndex_task(options) { + if (!gSuggestedIndexTaskIndex) { + initSuggestedIndexTest(); + } + let testIndex = gSuggestedIndexTaskIndex++; + let testName = "test_" + testIndex; + let testDesc = JSON.stringify(options); + let func = async () => { + info(`Running task at index ${testIndex}: ${testDesc}`); + await doSuggestedIndexTest(options); + }; + Object.defineProperty(func, "name", { value: testName }); + add_task(func); +} + +/** + * Initializes suggestedIndex tests. You don't normally need to call this from + * your test because add_suggestedIndex_task() calls it automatically. + */ +function initSuggestedIndexTest() { + // These tests can time out on Mac TV WebRender just because they do so much, + // so request a longer timeout. + if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); + } + registerCleanupFunction(() => { + gSuggestedIndexTaskIndex = 0; + }); +} + +/** + * @typedef {object} SuggestedIndexTestOptions + * @property {number} [otherCount] + * The number of results other than the heuristic and suggestedIndex results + * that the provider should return for search 1. This and `otherType` must be + * given together. + * @property {UrlbarUtils.RESULT_TYPE} [otherType] + * The type of results other than the heuristic and suggestedIndex results + * that the provider should return for search 1. This and `otherCount` must be + * given together. + * @property {Array} [other] + * If you want the provider to return a mix of result types instead of only + * one type, then use this param instead of `otherCount` and `otherType`. Each + * item in this array must be an object with the following properties: + * {number} count + * The number of results to return for the given `type`. + * {UrlbarUtils.RESULT_TYPE} type + * The type of results. + * @property {number} viewCount + * The total number of results expected in the view after search 1 finishes, + * including the heuristic and suggestedIndex results. + * @param {number} [suggestedIndex] + * If given, the provider will return a result with this suggested index for + * search 1. + * @property {number} [resultSpan] + * If this and `search1.suggestedIndex` are given, then the suggestedIndex + * result for search 1 will have this resultSpan. + * @property {Array} [suggestedIndexes] + * If you want the provider to return more than one suggestedIndex result for + * search 1, then use this instead of `search1.suggestedIndex`. Each item in + * this array must be one of the following: + * suggestedIndex value + * [suggestedIndex, resultSpan] tuple + */ + +/** + * Runs a suggestedIndex test. Performs two searches and checks the results just + * after the view update and after the second search finishes. The caller is + * responsible for passing in a description of what the rows should look like + * just after the view update finishes but before the second search finishes, + * i.e., before stale rows are removed and hidden rows are shown -- this is the + * `duringUpdate` param. The important thing this checks is that the rows with + * suggested indexes don't move around or appear in the wrong places. + * + * @param {object} options + * The options object + * @param {SuggestedIndexTestOptions} options.search1 + * The first search options object + * @param {SuggestedIndexTestOptions} options.search2 + * This object has the same properties as the `search1` object but it applies + * to the second search. + * @param {Array<{ count: number, type: UrlbarUtils.RESULT_TYPE, suggestedIndex: ?number, stale: ?boolean, hidden: ?boolean }>} options.duringUpdate + * An array of expected row states during the view update. Each item in the + * array must be an object with the following properties: + * {number} count + * The number of rows in the view to which this row state object applies. + * {UrlbarUtils.RESULT_TYPE} type + * The expected type of the rows. + * {number} [suggestedIndex] + * The expected suggestedIndex of the row. + * {boolean} [stale] + * Whether the rows are expected to be stale. Defaults to false. + * {boolean} [hidden] + * Whether the rows are expected to be hidden. Defaults to false. + */ +async function doSuggestedIndexTest({ search1, search2, duringUpdate }) { + // We use this test provider to test specific results. It has an Infinity + // priority so that it provides all results in our test searches, including + // the heuristic. That lets us avoid any potential races with the built-in + // providers; testing them is not important here. + let provider = new DelayingTestProvider({ priority: Infinity }); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(provider); + }); + + // Set up the first search. First, add the non-suggestedIndex results to the + // provider. + provider._results = makeProviderResults({ + specs: search1.other, + count: search1.otherCount, + type: search1.otherType, + }); + + // Set up `suggestedIndexes`. It's an array with [suggestedIndex, resultSpan] + // tuples. + if (!search1.suggestedIndexes) { + search1.suggestedIndexes = []; + } + search1.suggestedIndexes = search1.suggestedIndexes.map(value => + typeof value == "number" ? [value, 1] : value + ); + if (typeof search1.suggestedIndex == "number") { + search1.suggestedIndexes.push([ + search1.suggestedIndex, + search1.resultSpan || 1, + ]); + } + + // Add the suggestedIndex results to the provider. + for (let [suggestedIndex, resultSpan] of search1.suggestedIndexes) { + provider._results.push( + makeSuggestedIndexResult(suggestedIndex, resultSpan) + ); + } + + // Do the first search. + provider.finishQueryPromise = Promise.resolve(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + // Sanity check the results. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + search1.viewCount, + "Row count after first search" + ); + for (let [suggestedIndex, resultSpan] of search1.suggestedIndexes) { + let index = + suggestedIndex >= 0 + ? Math.min(search1.viewCount - 1, suggestedIndex) + : Math.max(0, search1.viewCount + suggestedIndex); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + result.element.row.result.suggestedIndex, + suggestedIndex, + "suggestedIndex after first search" + ); + Assert.equal( + UrlbarUtils.getSpanForResult(result.element.row.result), + resultSpan, + "resultSpan after first search" + ); + } + + // Set up the second search. First, add the non-suggestedIndex results to the + // provider. + provider._results = makeProviderResults({ + specs: search2.other, + count: search2.otherCount, + type: search2.otherType, + }); + + // Set up `suggestedIndexes`. It's an array with [suggestedIndex, resultSpan] + // tuples. + if (!search2.suggestedIndexes) { + search2.suggestedIndexes = []; + } + search2.suggestedIndexes = search2.suggestedIndexes.map(value => + typeof value == "number" ? [value, 1] : value + ); + if (typeof search2.suggestedIndex == "number") { + search2.suggestedIndexes.push([ + search2.suggestedIndex, + search2.resultSpan || 1, + ]); + } + + // Add the suggestedIndex results to the provider. + for (let [suggestedIndex, resultSpan] of search2.suggestedIndexes) { + provider._results.push( + makeSuggestedIndexResult(suggestedIndex, resultSpan) + ); + } + + let rowCountDuringUpdate = duringUpdate.reduce( + (count, rowState) => count + rowState.count, + 0 + ); + + // Don't allow the search to finish until we check the updated rows. We'll + // accomplish that by adding a mutation observer to observe completion of the + // update and delaying resolving the provider's finishQueryPromise. + let mutationPromise = new Promise(resolve => { + let lastRowState = duringUpdate[duringUpdate.length - 1]; + let observer = new MutationObserver(mutations => { + observer.disconnect(); + resolve(); + }); + if (lastRowState.stale) { + // The last row during the update is expected to become stale. Wait for + // the stale attribute to be set on it. We'll actually just wait for any + // attribute. + let { children } = UrlbarTestUtils.getResultsContainer(window); + observer.observe(children[children.length - 1], { attributes: true }); + } else if (search1.viewCount == rowCountDuringUpdate) { + // No rows are expected to be added during the view update, so it must be + // the case that some rows will be updated for results in the the second + // search. Wait for any change to an existing row. + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + subtree: true, + attributes: true, + characterData: true, + }); + } else { + // Rows are expected to be added during the update. Wait for them. + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + childList: true, + }); + } + }); + + // Now do the second search but don't wait for it to finish. + let resolveQuery; + provider.finishQueryPromise = new Promise( + resolve => (resolveQuery = resolve) + ); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + // Wait for the update to finish. + await mutationPromise; + + // Check the rows. We can't use UrlbarTestUtils.getDetailsOfResultAt() here + // because it waits for the search to finish. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + rowCountDuringUpdate, + "Row count during update" + ); + let rows = UrlbarTestUtils.getResultsContainer(window).children; + let rowIndex = 0; + for (let rowState of duringUpdate) { + for (let i = 0; i < rowState.count; i++) { + let row = rows[rowIndex]; + + // type + if ("type" in rowState) { + Assert.equal( + row.result.type, + rowState.type, + `Type at index ${rowIndex} during update` + ); + } + + // suggestedIndex + if ("suggestedIndex" in rowState) { + Assert.ok( + row.result.hasSuggestedIndex, + `Row at index ${rowIndex} has suggestedIndex during update` + ); + Assert.equal( + row.result.suggestedIndex, + rowState.suggestedIndex, + `suggestedIndex at index ${rowIndex} during update` + ); + } else { + Assert.ok( + !row.result.hasSuggestedIndex, + `Row at index ${rowIndex} does not have suggestedIndex during update` + ); + } + + // resultSpan + Assert.equal( + UrlbarUtils.getSpanForResult(row.result), + rowState.resultSpan || 1, + `resultSpan at index ${rowIndex} during update` + ); + + // stale + if (rowState.stale) { + Assert.equal( + row.getAttribute("stale"), + "true", + `Row at index ${rowIndex} is stale during update` + ); + } else { + Assert.ok( + !row.hasAttribute("stale"), + `Row at index ${rowIndex} is not stale during update` + ); + } + + // visible + Assert.equal( + BrowserTestUtils.is_visible(row), + !rowState.hidden, + `Visible at index ${rowIndex} during update` + ); + + rowIndex++; + } + } + + // Finish the search. + resolveQuery(); + await queryPromise; + + // Check the rows now that the second search is done. First, build a map from + // real indexes to suggested index. e.g., if a suggestedIndex = -1, then the + // real index = the result count - 1. + let suggestedIndexesByRealIndex = new Map(); + for (let [suggestedIndex, resultSpan] of search2.suggestedIndexes) { + let realIndex = + suggestedIndex >= 0 + ? Math.min(suggestedIndex, search2.viewCount - 1) + : Math.max(0, search2.viewCount + suggestedIndex); + suggestedIndexesByRealIndex.set(realIndex, [suggestedIndex, resultSpan]); + } + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + search2.viewCount, + "Row count after update" + ); + for (let i = 0; i < search2.viewCount; i++) { + let result = rows[i].result; + let tuple = suggestedIndexesByRealIndex.get(i); + if (tuple) { + let [suggestedIndex, resultSpan] = tuple; + Assert.ok( + result.hasSuggestedIndex, + `Row at index ${i} has suggestedIndex after update` + ); + Assert.equal( + result.suggestedIndex, + suggestedIndex, + `suggestedIndex at index ${i} after update` + ); + Assert.equal( + UrlbarUtils.getSpanForResult(result), + resultSpan, + `resultSpan at index ${i} after update` + ); + } else { + Assert.ok( + !result.hasSuggestedIndex, + `Row at index ${i} does not have suggestedIndex after update` + ); + } + } + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + UrlbarProvidersManager.unregisterProvider(provider); +} diff --git a/browser/components/urlbar/tests/browser/POSTSearchEngine.xml b/browser/components/urlbar/tests/browser/POSTSearchEngine.xml new file mode 100644 index 0000000000..8b387ea9ae --- /dev/null +++ b/browser/components/urlbar/tests/browser/POSTSearchEngine.xml @@ -0,0 +1,6 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> + <ShortName>POST Search</ShortName> + <Url type="text/html" method="POST" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs"> + <Param name="searchterms" value="{searchTerms}"/> + </Url> +</OpenSearchDescription> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_0.xml b/browser/components/urlbar/tests/browser/add_search_engine_0.xml new file mode 100644 index 0000000000..a24348deb7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_0.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>add_search_engine_0</ShortName> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_1.xml b/browser/components/urlbar/tests/browser/add_search_engine_1.xml new file mode 100644 index 0000000000..61092247a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_1.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>add_search_engine_1</ShortName> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_2.xml b/browser/components/urlbar/tests/browser/add_search_engine_2.xml new file mode 100644 index 0000000000..3f5c2f0037 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_2.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>add_search_engine_2</ShortName> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_3.xml b/browser/components/urlbar/tests/browser/add_search_engine_3.xml new file mode 100644 index 0000000000..bacfffa3e2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_3.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>add_search_engine_3</ShortName> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_invalid.html b/browser/components/urlbar/tests/browser/add_search_engine_invalid.html new file mode 100644 index 0000000000..ea5baa93d5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_invalid.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_404" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_404.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_many.html b/browser/components/urlbar/tests/browser/add_search_engine_many.html new file mode 100644 index 0000000000..d75bccc890 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_many.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="/> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_0" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_0.xml"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_1" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_1.xml"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_2" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_2.xml"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_3" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_3.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_one.html b/browser/components/urlbar/tests/browser/add_search_engine_one.html new file mode 100644 index 0000000000..c11409604f --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_one.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="/> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_0" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_0.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_same_names.html b/browser/components/urlbar/tests/browser/add_search_engine_same_names.html new file mode 100644 index 0000000000..7112905a75 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_same_names.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_0" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_0.xml"> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_0" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_0.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/urlbar/tests/browser/add_search_engine_two.html b/browser/components/urlbar/tests/browser/add_search_engine_two.html new file mode 100644 index 0000000000..c1d33860a4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/add_search_engine_two.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="/> +<link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_0" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_0.xml"> + <link rel="search" + type="application/opensearchdescription+xml" + title="add_search_engine_1" + href="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/add_search_engine_2.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/urlbar/tests/browser/authenticate.sjs b/browser/components/urlbar/tests/browser/authenticate.sjs new file mode 100644 index 0000000000..8218a46eb6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/authenticate.sjs @@ -0,0 +1,218 @@ +"use strict"; + +function handleRequest(request, response) { + try { + reallyHandleRequest(request, response); + } catch (e) { + response.setStatusLine("1.0", 200, "AlmostOK"); + response.write("Error handling request: " + e); + } +} + +function reallyHandleRequest(request, response) { + let match; + let requestAuth = true, + requestProxyAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + let query = "?" + request.queryString; + + let expected_user = "", + expected_pass = "", + realm = "mochitest"; + let proxy_expected_user = "", + proxy_expected_pass = "", + proxy_realm = "mochi-proxy"; + let huge = false, + plugin = false, + anonymous = false; + let authHeaderCount = 1; + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) { + expected_user = match[1]; + } + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) { + expected_pass = match[1]; + } + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) { + realm = match[1]; + } + + // proxy_user=xxx + match = /proxy_user=([^&]*)/.exec(query); + if (match) { + proxy_expected_user = match[1]; + } + + // proxy_pass=xxx + match = /proxy_pass=([^&]*)/.exec(query); + if (match) { + proxy_expected_pass = match[1]; + } + + // proxy_realm=xxx + match = /proxy_realm=([^&]*)/.exec(query); + if (match) { + proxy_realm = match[1]; + } + + // huge=1 + match = /huge=1/.exec(query); + if (match) { + huge = true; + } + + // plugin=1 + match = /plugin=1/.exec(query); + if (match) { + plugin = true; + } + + // multiple=1 + match = /multiple=([^&]*)/.exec(query); + if (match) { + authHeaderCount = match[1] + 0; + } + + // anonymous=1 + match = /anonymous=1/.exec(query); + if (match) { + anonymous = true; + } + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + let actual_user = "", + actual_pass = "", + authHeader, + authPresent = false; + if (request.hasHeader("Authorization")) { + authPresent = true; + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + } + + let proxy_actual_user = "", + proxy_actual_pass = ""; + if (request.hasHeader("Proxy-Authorization")) { + authHeader = request.getHeader("Proxy-Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw Error("Couldn't parse auth header: " + authHeader); + } + + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw Error("Couldn't decode auth header: " + userpass); + } + proxy_actual_user = match[1]; + proxy_actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && expected_pass == actual_pass) { + requestAuth = false; + } + if ( + proxy_expected_user == proxy_actual_user && + proxy_expected_pass == proxy_actual_pass + ) { + requestProxyAuth = false; + } + + if (anonymous) { + if (authPresent) { + response.setStatusLine( + "1.0", + 400, + "Unexpected authorization header found" + ); + } else { + response.setStatusLine("1.0", 200, "Authorization header not found"); + } + } else if (requestProxyAuth) { + response.setStatusLine("1.0", 407, "Proxy authentication required"); + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "Proxy-Authenticate", + 'basic realm="' + proxy_realm + '"', + true + ); + } + } else if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + for (let i = 0; i < authHeaderCount; ++i) { + response.setHeader( + "WWW-Authenticate", + 'basic realm="' + realm + '"', + true + ); + } + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write( + "<p>Login: <span id='ok'>" + + (requestAuth ? "FAIL" : "PASS") + + "</span></p>\n" + ); + response.write( + "<p>Proxy: <span id='proxy'>" + + (requestProxyAuth ? "FAIL" : "PASS") + + "</span></p>\n" + ); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + + if (huge) { + response.write("<div style='display: none'>"); + for (let i = 0; i < 100000; i++) { + response.write("123456789\n"); + } + response.write("</div>"); + response.write( + "<span id='footnote'>This is a footnote after the huge content fill</span>" + ); + } + + if (plugin) { + response.write( + "<embed id='embedtest' style='width: 400px; height: 100px;' " + + "type='application/x-test'></embed>\n" + ); + } + + response.write("</html>"); +} diff --git a/browser/components/urlbar/tests/browser/browser.ini b/browser/components/urlbar/tests/browser/browser.ini new file mode 100644 index 0000000000..64290236d3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser.ini @@ -0,0 +1,434 @@ +# 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/. + +[DEFAULT] +support-files = + dummy_page.html + head.js + head-common.js +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure + +prefs = + browser.urlbar.trending.featureGate=false + extensions.screenshots.disabled=false + screenshots.browser.component.enabled=true + +[browser_UrlbarInput_formatValue.js] +[browser_UrlbarInput_formatValue_detachedTab.js] +skip-if = + apple_catalina # Bug 1756585 + os == 'win' # Bug 1756585 +[browser_UrlbarInput_hiddenFocus.js] +[browser_UrlbarInput_overflow.js] +[browser_UrlbarInput_overflow_resize.js] +[browser_UrlbarInput_privateFeature.js] +[browser_UrlbarInput_searchTerms.js] +[browser_UrlbarInput_searchTerms_backgroundTabs.js] +[browser_UrlbarInput_searchTerms_modifiedUrl.js] +[browser_UrlbarInput_searchTerms_moveTab.js] +[browser_UrlbarInput_searchTerms_popup.js] +[browser_UrlbarInput_searchTerms_revert.js] +[browser_UrlbarInput_searchTerms_searchBar.js] +[browser_UrlbarInput_searchTerms_searchMode.js] +[browser_UrlbarInput_searchTerms_switch_tab.js] +[browser_UrlbarInput_searchTerms_telemetry.js] +[browser_UrlbarInput_setURI.js] +https_first_disabled = true +skip-if = + apple_catalina && debug # Bug 1773790 +[browser_UrlbarInput_tooltip.js] +[browser_UrlbarInput_trimURLs.js] +https_first_disabled = true +[browser_aboutHomeLoading.js] +skip-if = + tsan # Intermittently times out, see 1622698 (frequent on TSan). + os == 'linux' && bits == 64 && !debug # Bug 1622698 +[browser_acknowledgeFeedbackAndDismissal.js] +[browser_action_searchengine.js] +[browser_action_searchengine_alias.js] +[browser_add_search_engine.js] +support-files = + add_search_engine_0.xml + add_search_engine_1.xml + add_search_engine_2.xml + add_search_engine_3.xml + add_search_engine_invalid.html + add_search_engine_one.html + add_search_engine_many.html + add_search_engine_same_names.html + add_search_engine_two.html +[browser_autoFill_backspaced.js] +[browser_autoFill_canonize.js] +https_first_disabled = true +[browser_autoFill_caretNotAtEnd.js] +[browser_autoFill_firstResult.js] +[browser_autoFill_paste.js] +[browser_autoFill_placeholder.js] +[browser_autoFill_preserve.js] +[browser_autoFill_trimURLs.js] +[browser_autoFill_typed.js] +[browser_autoFill_undo.js] +[browser_autoOpen.js] +[browser_autocomplete_a11y_label.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_autocomplete_autoselect.js] +[browser_autocomplete_cursor.js] +[browser_autocomplete_edit_completed.js] +[browser_autocomplete_enter_race.js] +https_first_disabled = true +[browser_autocomplete_no_title.js] +[browser_autocomplete_readline_navigation.js] +skip-if = os != "mac" # Mac only feature +[browser_autocomplete_tag_star_visibility.js] +[browser_bestMatch.js] +[browser_blanking.js] +support-files = + file_blank_but_not_blank.html +[browser_bufferer_onQueryResults.js] +[browser_calculator.js] +[browser_canonizeURL.js] +https_first_disabled = true +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_caret_position.js] +[browser_click_row_border.js] +[browser_closePanelOnClick.js] +[browser_content_opener.js] +[browser_contextualsearch.js] +[browser_copy_during_load.js] +support-files = + slow-page.sjs +[browser_copying.js] +https_first_disabled = true +support-files = + authenticate.sjs +[browser_customizeMode.js] +[browser_cutting.js] +[browser_decode.js] +[browser_delete.js] +[browser_deleteAllText.js] +[browser_display_selectedAction_Extensions.js] +[browser_dns_first_for_single_words.js] +skip-if = verify && os == 'linux' # Bug 1581635 +[browser_downArrowKeySearch.js] +https_first_disabled = true +[browser_dragdropURL.js] +[browser_dynamicResults.js] +https_first_disabled = true +support-files = + dynamicResult0.css + dynamicResult1.css +[browser_edit_invalid_url.js] +[browser_engagement.js] +[browser_enter.js] +[browser_enterAfterMouseOver.js] +[browser_focusedCmdK.js] +[browser_groupLabels.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_handleCommand_fallback.js] +[browser_hashChangeProxyState.js] +[browser_helpUrl.js] +[browser_heuristicNotAddedFirst.js] +[browser_hideHeuristic.js] +[browser_ime_composition.js] +[browser_inputHistory.js] +https_first_disabled = true +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_inputHistory_autofill.js] +[browser_inputHistory_emptystring.js] +[browser_keepStateAcrossTabSwitches.js] +https_first_disabled = true +[browser_keyword.js] +support-files = + print_postdata.sjs + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_keywordBookmarklets.js] +[browser_keywordSearch.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_keywordSearch_postData.js] +support-files = + POSTSearchEngine.xml + print_postdata.sjs +[browser_keyword_override.js] +[browser_keyword_select_and_type.js] +[browser_loadRace.js] +[browser_locationBarCommand.js] +https_first_disabled = true +skip-if = + os == 'linux' && bits == 64 && !debug # Bug 1787020 +[browser_locationBarExternalLoad.js] +[browser_locationchange_urlbar_edit_dos.js] +support-files = + file_urlbar_edit_dos.html +[browser_middleClick.js] +[browser_new_tab_urlbar_reset.js] +[browser_oneOffs.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_oneOffs_contextMenu.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_oneOffs_heuristicRestyle.js] +skip-if = + os == "linux" && bits == 64 && !debug # Bug 1775811 +[browser_oneOffs_keyModifiers.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_oneOffs_searchSuggestions.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + searchSuggestionEngine2.xml +[browser_oneOffs_settings.js] +[browser_pasteAndGo.js] +https_first_disabled = true +[browser_paste_multi_lines.js] +[browser_paste_then_focus.js] +[browser_paste_then_switch_tab.js] +[browser_percent_encoded.js] +[browser_placeholder.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine2.xml + searchSuggestionEngine.sjs +[browser_populateAfterPushState.js] +[browser_primary_selection_safe_on_new_tab.js] +[browser_privateBrowsingWindowChange.js] +[browser_queryContextCache.js] +[browser_quickactions.js] +skip-if = + os == "linux" # Bug 1806090 +[browser_quickactions_devtools.js] +[browser_quickactions_tab_refocus.js] +[browser_raceWithTabs.js] +[browser_redirect_error.js] +support-files = redirect_error.sjs +[browser_remoteness_switch.js] +https_first_disabled = true +[browser_remotetab.js] +[browser_removeUnsafeProtocolsFromURLBarPaste.js] +[browser_remove_match.js] +[browser_restoreEmptyInput.js] +[browser_resultSpan.js] +[browser_result_menu.js] +[browser_result_onSelection.js] +[browser_retainedResultsOnFocus.js] +[browser_revert.js] +[browser_searchFunction.js] +[browser_searchHistoryLimit.js] +[browser_searchMode_alias_replacement.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchMode_autofill.js] +[browser_searchMode_clickLink.js] +https_first_disabled = true +support-files = + dummy_page.html +[browser_searchMode_engineRemoval.js] +[browser_searchMode_excludeResults.js] +[browser_searchMode_heuristic.js] +https_first_disabled = true +[browser_searchMode_indicator.js] +https_first_disabled = true +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchMode_indicator_clickthrough.js] +[browser_searchMode_localOneOffs_actionText.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchMode_newWindow.js] +[browser_searchMode_no_results.js] +[browser_searchMode_oneOffButton.js] +[browser_searchMode_pickResult.js] +https_first_disabled = true +[browser_searchMode_preview.js] +[browser_searchMode_sessionStore.js] +https_first_disabled = true +skip-if = os == 'mac' && debug && verify # bug 1671045 +[browser_searchMode_setURI.js] +https_first_disabled = true +[browser_searchMode_suggestions.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + searchSuggestionEngineMany.xml +[browser_searchMode_switchTabs.js] +[browser_searchSettings.js] +[browser_searchSingleWordNotification.js] +https_first_disabled = true +skip-if = + os == 'linux' && bits == 64 # Bug 1773830 +[browser_searchSuggestions.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchTelemetry.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_search_bookmarks_from_bookmarks_menu.js] +[browser_search_history_from_history_panel.js] +[browser_selectStaleResults.js] +support-files = + searchSuggestionEngineSlow.xml + searchSuggestionEngine.sjs +[browser_selectionKeyNavigation.js] +[browser_separatePrivateDefault.js] +support-files = + POSTSearchEngine.xml + print_postdata.sjs + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + searchSuggestionEngine2.xml +[browser_separatePrivateDefault_differentEngine.js] +support-files = + POSTSearchEngine.xml + print_postdata.sjs + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + searchSuggestionEngine2.xml +[browser_shortcuts_add_search_engine.js] +support-files = + add_search_engine_many.html + add_search_engine_two.html + add_search_engine_0.xml + add_search_engine_1.xml +[browser_speculative_connect.js] +support-files = + searchSuggestionEngine2.xml + searchSuggestionEngine.sjs +[browser_speculative_connect_not_with_client_cert.js] +[browser_stop.js] +[browser_stopSearchOnSelection.js] +support-files = + searchSuggestionEngineSlow.xml + searchSuggestionEngine.sjs +[browser_stop_pending.js] +https_first_disabled = true +support-files = + slow-page.sjs +[browser_strip_on_share.js] +[browser_suggestedIndex.js] +[browser_suppressFocusBorder.js] +[browser_switchTab_closesUrlbarPopup.js] +[browser_switchTab_decodeuri.js] +[browser_switchTab_inputHistory.js] +[browser_switchTab_override.js] +[browser_switchToTabHavingURI_aOpenParams.js] +[browser_switchToTab_chiclet.js] +[browser_switchToTab_closes_newtab.js] +[browser_switchToTab_fullUrl_repeatedKeydown.js] +[browser_tabKeyBehavior.js] +[browser_tabMatchesInAwesomebar.js] +support-files = + moz.png +[browser_tabMatchesInAwesomebar_perwindowpb.js] +[browser_tabToSearch.js] +[browser_textruns.js] +[browser_tokenAlias.js] +[browser_top_sites.js] +https_first_disabled = true +[browser_top_sites_private.js] +https_first_disabled = true +[browser_typed_value.js] +[browser_unitConversion.js] +[browser_updateForDomainCompletion.js] +https_first_disabled = true +[browser_urlbar_annotation.js] +support-files = + redirect_to.sjs +[browser_urlbar_event_telemetry_abandonment.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +skip-if = os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_urlbar_event_telemetry_engagement.js] +https_first_disabled = true +skip-if = + apple_catalina # Bug 1625690 + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + os == 'linux' # Bug 1748986, bug 1775824 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_urlbar_event_telemetry_noEvent.js] +[browser_urlbar_selection.js] +skip-if = (os == 'mac') # bug 1570474 +[browser_urlbar_telemetry.js] +tags = search-telemetry +support-files = + urlbarTelemetrySearchSuggestions.sjs + urlbarTelemetrySearchSuggestions.xml +[browser_urlbar_telemetry_autofill.js] +tags = search-telemetry +[browser_urlbar_telemetry_dynamic.js] +tags = search-telemetry +support-files = + urlbarTelemetryUrlbarDynamic.css +[browser_urlbar_telemetry_extension.js] +tags = search-telemetry +[browser_urlbar_telemetry_handoff.js] +tags = search-telemetry +[browser_urlbar_telemetry_persisted.js] +tags = search-telemetry +[browser_urlbar_telemetry_places.js] +https_first_disabled = true +tags = search-telemetry +[browser_urlbar_telemetry_quickactions.js] +tags = search-telemetry +[browser_urlbar_telemetry_remotetab.js] +tags = search-telemetry +[browser_urlbar_telemetry_searchmode.js] +tags = search-telemetry +support-files = + urlbarTelemetrySearchSuggestions.sjs + urlbarTelemetrySearchSuggestions.xml +[browser_urlbar_telemetry_sponsored_topsites.js] +https_first_disabled = true +tags = search-telemetry +[browser_urlbar_telemetry_tabtosearch.js] +tags = search-telemetry +[browser_urlbar_telemetry_tip.js] +tags = search-telemetry +[browser_urlbar_telemetry_topsite.js] +tags = search-telemetry +[browser_urlbar_telemetry_zeroPrefix.js] +tags = search-telemetry +[browser_userTypedValue.js] +support-files = file_userTypedValue.html +[browser_valueOnTabSwitch.js] +[browser_view_emptyResultSet.js] +[browser_view_resultDisplay.js] +[browser_view_resultTypes_display.js] +support-files = + print_postdata.sjs + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_view_selectionByMouse.js] +skip-if = + os == "linux" && asan # Bug 1789051 +[browser_waitForLoadOrTimeout.js] +https_first_disabled = true +skip-if = + tsan # Bug 1683730 + os == "linux" && bits == 64 && !debug # Bug 1666092 +[browser_whereToOpen.js] diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue.js new file mode 100644 index 0000000000..d4b73603f9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that the url formatter properly recognizes the host and de-emphasizes +// the rest of the url. + +/** + * Tests a given url. + * The de-emphasized parts must be wrapped in "<" and ">" chars. + * + * @param {string} aExpected The url to test. + * @param {string} aClobbered [optional] Normally the url is de-emphasized + * in-place, thus it's enough to pass aExpected. Though, in some cases + * the formatter may decide to replace the url with a fixed one, because + * it can't properly guess a host. In that case aClobbered is the + * expected de-emphasized value. + * @param {boolean} synthesizeInput [optional] Whether to synthesize an input + * event to test. + */ +function testVal(aExpected, aClobbered = null, synthesizeInput = false) { + let str = aExpected.replace(/[<>]/g, ""); + if (synthesizeInput) { + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendString(str); + Assert.equal( + gURLBar.editor.rootElement.textContent, + str, + "Url is not highlighted" + ); + gBrowser.selectedBrowser.focus(); + } else { + gURLBar.value = str; + } + + let selectionController = gURLBar.editor.selectionController; + let selection = selectionController.getSelection( + selectionController.SELECTION_URLSECONDARY + ); + let value = gURLBar.editor.rootElement.textContent; + let result = ""; + for (let i = 0; i < selection.rangeCount; i++) { + let range = selection.getRangeAt(i).toString(); + let pos = value.indexOf(range); + result += value.substring(0, pos) + "<" + range + ">"; + value = value.substring(pos + range.length); + } + result += value; + Assert.equal( + result, + aClobbered || aExpected, + "Correct part of the url is de-emphasized" + + (synthesizeInput ? " (with input simulation)" : "") + ); + + // Now re-test synthesizing input. + if (!synthesizeInput) { + testVal(aExpected, aClobbered, true); + } +} + +function test() { + const prefname = "browser.urlbar.formatting.enabled"; + + registerCleanupFunction(function () { + Services.prefs.clearUserPref(prefname); + gURLBar.setURI(); + }); + + gBrowser.selectedBrowser.focus(); + + testVal("<https://>mozilla.org"); + testVal("<https://>mözilla.org"); + testVal("<https://>mozilla.imaginatory"); + + testVal("<https://www.>mozilla.org"); + testVal("<https://sub.>mozilla.org"); + testVal("<https://sub1.sub2.sub3.>mozilla.org"); + testVal("<www.>mozilla.org"); + testVal("<sub.>mozilla.org"); + testVal("<sub1.sub2.sub3.>mozilla.org"); + testVal("<mozilla.com.>mozilla.com"); + testVal("<https://mozilla.com:mozilla.com@>mozilla.com"); + testVal("<mozilla.com:mozilla.com@>mozilla.com"); + + testVal("<ftp.>mozilla.org"); + testVal("<ftp://ftp.>mozilla.org"); + + testVal("<https://sub.>mozilla.org"); + testVal("<https://sub1.sub2.sub3.>mozilla.org"); + testVal("<https://user:pass@sub1.sub2.sub3.>mozilla.org"); + testVal("<https://user:pass@>mozilla.org"); + testVal("<user:pass@sub1.sub2.sub3.>mozilla.org"); + testVal("<user:pass@>mozilla.org"); + + testVal("<https://>mozilla.org< >"); + testVal("mozilla.org< >"); + // RTL characters in domain change order of domain and suffix. Domain should + // be highlighted correctly. + testVal("<http://>اختبار.اختبار</www.mozilla.org/index.html>"); + + testVal("<https://>mozilla.org</file.ext>"); + testVal("<https://>mozilla.org</sub/file.ext>"); + testVal("<https://>mozilla.org</sub/file.ext?foo>"); + testVal("<https://>mozilla.org</sub/file.ext?foo&bar>"); + testVal("<https://>mozilla.org</sub/file.ext?foo&bar#top>"); + testVal("<https://>mozilla.org</sub/file.ext?foo&bar#top>"); + testVal("foo.bar<?q=test>"); + testVal("foo.bar<#mozilla.org>"); + testVal("foo.bar<?somewhere.mozilla.org>"); + testVal("foo.bar<?@mozilla.org>"); + testVal("foo.bar<#x@mozilla.org>"); + testVal("foo.bar<#@x@mozilla.org>"); + testVal("foo.bar<?x@mozilla.org>"); + testVal("foo.bar<?@x@mozilla.org>"); + testVal("<foo.bar@x@>mozilla.org"); + testVal("<foo.bar@:baz@>mozilla.org"); + testVal("<foo.bar:@baz@>mozilla.org"); + testVal("<foo.bar@:ba:z@>mozilla.org"); + testVal("<foo.:bar:@baz@>mozilla.org"); + testVal( + "foopy:\\blah@somewhere.com//whatever/", + "foopy</blah@somewhere.com//whatever/>" + ); + + testVal("<https://sub.>mozilla.org<:666/file.ext>"); + testVal("<sub.>mozilla.org<:666/file.ext>"); + testVal("localhost<:666/file.ext>"); + + let IPs = [ + "192.168.1.1", + "[::]", + "[::1]", + "[1::]", + "[::]", + "[::1]", + "[1::]", + "[1:2:3:4:5:6:7::]", + "[::1:2:3:4:5:6:7]", + "[1:2:a:B:c:D:e:F]", + "[1::8]", + "[1:2::8]", + "[fe80::222:19ff:fe11:8c76]", + "[0000:0123:4567:89AB:CDEF:abcd:ef00:0000]", + "[::192.168.1.1]", + "[1::0.0.0.0]", + "[1:2::255.255.255.255]", + "[1:2:3::255.255.255.255]", + "[1:2:3:4::255.255.255.255]", + "[1:2:3:4:5::255.255.255.255]", + "[1:2:3:4:5:6:255.255.255.255]", + ]; + IPs.forEach(function (IP) { + testVal(IP); + testVal(IP + "</file.ext>"); + testVal(IP + "<:666/file.ext>"); + testVal("<https://>" + IP); + testVal(`<https://>${IP}</file.ext>`); + testVal(`<https://user:pass@>${IP}<:666/file.ext>`); + testVal(`<user:pass@>${IP}<:666/file.ext>`); + testVal(`user:\\pass@${IP}/`, `user</pass@${IP}/>`); + }); + + testVal("mailto:admin@mozilla.org"); + testVal("gopher://mozilla.org/"); + testVal("about:config"); + testVal("jar:http://mozilla.org/example.jar!/"); + testVal("view-source:http://mozilla.org/"); + testVal("foo9://mozilla.org/"); + testVal("foo+://mozilla.org/"); + testVal("foo.://mozilla.org/"); + testVal("foo-://mozilla.org/"); + + // Disable formatting. + Services.prefs.setBoolPref(prefname, false); + + testVal("https://mozilla.org"); +} diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue_detachedTab.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue_detachedTab.js new file mode 100644 index 0000000000..02da2a8e2b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue_detachedTab.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// After detaching a tab into a new window, the input value in the new window +// should be formatted. + +add_task(async function detach() { + // Sometimes the value isn't formatted on Mac when running in verify chaos + // mode. The usual, proper front-end code path is hit, and the path that + // removes formatting is not hit, so it seems like some kind of race in the + // editor or selection code in Gecko. Since this has only been observed on Mac + // in chaos mode and doesn't seem to be a problem in urlbar code, skip the + // test in that case. + if (AppConstants.platform == "macosx" && Services.env.get("MOZ_CHAOSMODE")) { + Assert.ok(true, "Skipping test in chaos mode on Mac"); + return; + } + + UrlbarPrefs.clear("formatting.enabled"); + Assert.ok( + UrlbarPrefs.get("formatting.enabled"), + "Formatting is enabled by default" + ); + + info("Waiting for new tab"); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "https://example.com/detach", + }); + + let winPromise = BrowserTestUtils.waitForNewWindow(); + info("Detaching tab"); + let win = gBrowser.replaceTabWithWindow(tab, {}); + info("Waiting for new window"); + await winPromise; + + // Wait an extra tick for good measure since the code itself also waits for + // `delayedStartupPromise`. + info("Waiting for delayed startup in new window"); + await win.delayedStartupPromise; + info("Waiting for tick"); + await TestUtils.waitForTick(); + + assertValue("<https://>example.com</detach>", win); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Asserts formatting in the input is correct. + * + * @param {string} expectedValue + * The URL to test. The parts the are expected to be de-emphasized should be + * wrapped in "<" and ">" chars. + * @param {window} win + * The input in this window will be tested. + */ +function assertValue(expectedValue, win = window) { + let selectionController = win.gURLBar.editor.selectionController; + let selection = selectionController.getSelection( + selectionController.SELECTION_URLSECONDARY + ); + let value = win.gURLBar.editor.rootElement.textContent; + let result = ""; + for (let i = 0; i < selection.rangeCount; i++) { + let range = selection.getRangeAt(i).toString(); + let pos = value.indexOf(range); + result += value.substring(0, pos) + "<" + range + ">"; + value = value.substring(pos + range.length); + } + result += value; + Assert.equal( + result, + expectedValue, + "Correct part of the url is de-emphasized" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_hiddenFocus.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_hiddenFocus.js new file mode 100644 index 0000000000..08e5ae97d3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_hiddenFocus.js @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + gURLBar.setURI(); + }); + + gURLBar.blur(); + ok(!gURLBar.focused, "url bar is not focused"); + ok(!gURLBar.hasAttribute("focused"), "url bar is not visibly focused"); + gURLBar.setHiddenFocus(); + ok(gURLBar.focused, "url bar is focused"); + ok(!gURLBar.hasAttribute("focused"), "url bar is not visibly focused"); + gURLBar.removeHiddenFocus(); + ok(gURLBar.focused, "url bar is focused"); + ok(gURLBar.hasAttribute("focused"), "url bar is visibly focused"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js new file mode 100644 index 0000000000..b0a3337d84 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow.js @@ -0,0 +1,156 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +async function testVal(aExpected, overflowSide = "") { + info(`Testing ${aExpected}`); + try { + gURLBar.setURI(makeURI(aExpected)); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_MALFORMED_URI) { + throw ex; + } + // For values without a protocol fallback to setting the raw value. + gURLBar.value = aExpected; + } + + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + "Selection sanity check" + ); + + gURLBar.focus(); + Assert.equal( + document.activeElement, + gURLBar.inputField, + "URL Bar should be focused" + ); + Assert.equal( + gURLBar.valueFormatter.scheme.value, + "", + "Check the scheme value" + ); + Assert.equal( + getComputedStyle(gURLBar.valueFormatter.scheme).visibility, + "hidden", + "Check the scheme box visibility" + ); + + gURLBar.blur(); + await window.promiseDocumentFlushed(() => {}); + // The attribute doesn't always change, so we can't use waitForAttribute. + await TestUtils.waitForCondition( + () => gURLBar.getAttribute("textoverflow") === overflowSide + ); + + let scheme = aExpected.match(/^([a-z]+:\/{0,2})/)?.[1] || ""; + // We strip http, so we should not show the scheme for it. + if ( + scheme == "http://" && + Services.prefs.getBoolPref("browser.urlbar.trimURLs", true) + ) { + scheme = ""; + } + + Assert.equal( + gURLBar.valueFormatter.scheme.value, + scheme, + "Check the scheme value" + ); + let isOverflowed = + gURLBar.inputField.scrollWidth > gURLBar.inputField.clientWidth; + Assert.equal(isOverflowed, !!overflowSide, "Check The input field overflow"); + Assert.equal( + gURLBar.getAttribute("textoverflow"), + overflowSide, + "Check the textoverflow attribute" + ); + if (overflowSide) { + let side = gURLBar.getAttribute("domaindir") == "ltr" ? "right" : "left"; + Assert.equal(side, overflowSide, "Check the overflow side"); + Assert.equal( + getComputedStyle(gURLBar.valueFormatter.scheme).visibility, + scheme && isOverflowed && overflowSide == "left" ? "visible" : "hidden", + "Check the scheme box visibility" + ); + + info("Focus, change scroll position and blur, to ensure proper restore"); + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_End"); + gURLBar.blur(); + await window.promiseDocumentFlushed(() => {}); + // The attribute doesn't always change, so we can't use waitForAttribute. + await TestUtils.waitForCondition( + () => gURLBar.getAttribute("textoverflow") === overflowSide + ); + + Assert.equal(side, overflowSide, "Check the overflow side"); + Assert.equal( + getComputedStyle(gURLBar.valueFormatter.scheme).visibility, + scheme && isOverflowed && overflowSide == "left" ? "visible" : "hidden", + "Check the scheme box visibility" + ); + } +} + +add_task(async function () { + // We use a new tab for the test to be sure all the tab switching and loading + // is complete before starting, otherwise onLocationChange for this tab could + // override the value we set with an empty value. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(function () { + gURLBar.setURI(); + BrowserTestUtils.removeTab(tab); + }); + + let lotsOfSpaces = "%20".repeat(200); + + // اسماء.شبكة + let rtlDomain = + "\u0627\u0633\u0645\u0627\u0621\u002e\u0634\u0628\u0643\u0629"; + let rtlChar = "\u0627"; + + // Mix the direction of the tests to cover more cases, and to ensure the + // textoverflow attribute changes every time, because tewtVal waits for that. + await testVal(`https://mozilla.org/${lotsOfSpaces}/test/`, "right"); + await testVal(`https://mozilla.org/`); + await testVal(`https://${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + await testVal(`https://mozilla.org:8888/${lotsOfSpaces}/test/`, "right"); + await testVal(`https://${rtlDomain}:8888/${lotsOfSpaces}/test/`, "left"); + + await testVal(`ftp://mozilla.org/${lotsOfSpaces}/test/`, "right"); + await testVal(`ftp://${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + await testVal(`ftp://mozilla.org/`); + + await testVal(`http://${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + await testVal(`http://mozilla.org/`); + await testVal(`http://mozilla.org/${lotsOfSpaces}/test/`, "right"); + await testVal(`http://${rtlDomain}:8888/${lotsOfSpaces}/test/`, "left"); + await testVal(`http://[::1]/${rtlChar}/${lotsOfSpaces}/test/`, "right"); + + info("Test with formatting disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.formatting.enabled", false], + ["browser.urlbar.trimURLs", false], + ], + }); + + await testVal(`https://mozilla.org/`); + await testVal(`https://${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + await testVal(`https://mozilla.org/${lotsOfSpaces}/test/`, "right"); + + info("Test with trimURLs disabled"); + await testVal(`http://${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + + await SpecialPowers.popPrefEnv(); + + info("Tests without protocol"); + await testVal(`mozilla.org/${lotsOfSpaces}/test/`, "right"); + await testVal(`mozilla.org/`); + await testVal(`${rtlDomain}/${lotsOfSpaces}/test/`, "left"); + await testVal(`mozilla.org:8888/${lotsOfSpaces}/test/`, "right"); + await testVal(`${rtlDomain}:8888/${lotsOfSpaces}/test/`, "left"); + await testVal(`[::1]/${rtlChar}/${lotsOfSpaces}/test/`, "right"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow_resize.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow_resize.js new file mode 100644 index 0000000000..879911d703 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_overflow_resize.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +async function testVal(win, url) { + info(`Testing ${url}`); + win.gURLBar.setURI(makeURI(url)); + + let urlbar = win.gURLBar; + urlbar.blur(); + + for (let width of [1000, 800]) { + win.resizeTo(width, 500); + await win.promiseDocumentFlushed(() => {}); + Assert.greater( + urlbar.inputField.scrollWidth, + urlbar.inputField.clientWidth, + "Check The input field overflows" + ); + // Resize is handled on a timer, so we must wait for it. + await TestUtils.waitForCondition( + () => urlbar.inputField.scrollLeft == urlbar.inputField.scrollLeftMax, + "The urlbar input field is completely scrolled to the end" + ); + await TestUtils.waitForCondition( + () => urlbar.getAttribute("textoverflow") == "left", + "Wait for the textoverflow attribute" + ); + } +} + +add_task(async function () { + // We use a new tab for the test to be sure all the tab switching and loading + // is complete before starting, otherwise onLocationChange for this tab could + // override the value we set with an empty value. + let win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + + let lotsOfSpaces = "%20".repeat(200); + + // اسماء.شبكة + let rtlDomain = + "\u0627\u0633\u0645\u0627\u0621\u002e\u0634\u0628\u0643\u0629"; + + // Mix the direction of the tests to cover more cases, and to ensure the + // textoverflow attribute changes every time, because tewtVal waits for that. + await testVal(win, `https://${rtlDomain}/${lotsOfSpaces}/test/`); + + info("Test with formatting and trimurl disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.formatting.enabled", false], + ["browser.urlbar.trimURLs", false], + ], + }); + + await testVal(win, `https://${rtlDomain}/${lotsOfSpaces}/test/`); + await testVal(win, `http://${rtlDomain}/${lotsOfSpaces}/test/`); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_privateFeature.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_privateFeature.js new file mode 100644 index 0000000000..fb81e9f536 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_privateFeature.js @@ -0,0 +1,74 @@ +/* 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/. */ + +// Tests that _loadURL correctly sets and passes on the `private` window +// attribute (or not) with various arguments. + +add_task(async function privateFeatureSetOnNewWindowImplicitly() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + privateWin.gURLBar._loadURL("about:blank", null, "window", {}); + + let newWin = await newWinOpened; + Assert.equal( + newWin.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags & + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + "New window opened from existing private window should be marked as private" + ); + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function privateFeatureSetOnNewWindowExplicitly() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + privateWin.gURLBar._loadURL("about:blank", null, "window", { private: true }); + + let newWin = await newWinOpened; + Assert.equal( + newWin.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags & + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + "New window opened from existing private window should be marked as private" + ); + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function privateFeatureNotSetOnNewWindowExplicitly() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let newWinOpened = BrowserTestUtils.waitForNewWindow(); + + privateWin.gURLBar._loadURL("about:blank", null, "window", { + private: false, + }); + + let newWin = await newWinOpened; + Assert.notEqual( + newWin.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags & + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, + "New window opened from existing private window should be marked as private" + ); + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms.js new file mode 100644 index 0000000000..3d38036d84 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms.js @@ -0,0 +1,306 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when loading a page +// whose url matches that of the default search engine. + +let defaultTestEngine; + +// The main search string used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +// Starts a search with a tab and asserts that +// the state of the Urlbar contains the search term +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + assertSearchStringIsInUrlbar(searchString); + + return { tab, expectedSearchUrl }; +} + +// Search terms should show up in the url bar if the pref is on +// and the SERP url matches the one constructed in Firefox +add_task(async function list_of_search_strings() { + const searches = [ + { + // Single word + searchString: "chocolate", + }, + { + // Word with space + searchString: "chocolate cake", + }, + { + // Special characters + searchString: "chocolate;,?:@&=+$-_.!~*'()#cake", + }, + { + searchString: '"chocolate cake" -recipes', + }, + { + // Search with special characters + searchString: "site:example.com chocolate -cake", + }, + ]; + + for (let { searchString } of searches) { + let { tab } = await searchWithTab(searchString); + BrowserTestUtils.removeTab(tab); + } +}); + +// If a user does a search, goes to another page, and then +// goes back to the SERP, the search term should show. +add_task(async function go_back() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "http://www.example.com/some_url" + ); + await browserLoadedPromise; + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + + assertSearchStringIsInUrlbar(SEARCH_STRING); + + BrowserTestUtils.removeTab(tab); +}); + +// Manually loading a url that matches a search query url +// should show the search term in the Urlbar. +add_task(async function load_url() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + BrowserTestUtils.loadURIString(tab.linkedBrowser, expectedSearchUrl); + await browserLoadedPromise; + assertSearchStringIsInUrlbar(SEARCH_STRING); + + BrowserTestUtils.removeTab(tab); +}); + +// Focusing and blurring the urlbar while the search terms +// persist should change the pageproxystate. +add_task(async function focus_and_unfocus() { + let { tab } = await searchWithTab(SEARCH_STRING); + + gURLBar.focus(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Should have matching pageproxystate." + ); + + gURLBar.blur(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Should have matching pageproxystate." + ); + + BrowserTestUtils.removeTab(tab); +}); + +// If the user modifies the search term, blurring the +// urlbar should keep the urlbar in an invalid pageproxystate. +add_task(async function focus_and_unfocus_modified() { + let { tab } = await searchWithTab(SEARCH_STRING); + + gURLBar.focus(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Should have matching pageproxystate." + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "another search term", + fireInputEvent: true, + }); + + gURLBar.blur(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Should have matching pageproxystate." + ); + + BrowserTestUtils.removeTab(tab); +}); + +// If Top Sites is cached in the UrlbarView, don't show it if the search terms +// persist in the Urlbar. +add_task(async function focus_after_top_sites() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Prevent the persist tip from interrupting clicking the Urlbar + // after the the SERP has been loaded. + [ + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.PERSIST}`, + 10000, + ], + ["browser.newtabpage.activity-stream.feeds.topsites", true], + ], + }); + + // Populate Top Sites on a clean version of Places. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.promiseAsyncUpdates(); + await TestUtils.waitForTick(); + + const urls = []; + const N_TOP_SITES = 5; + const N_VISITS = 5; + + for (let i = 0; i < N_TOP_SITES; i++) { + let url = `https://${i}.example.com/hello_world${i}`; + urls.unshift(url); + // Each URL needs to be added several times to boost its frecency enough to + // qualify as a top site. + for (let j = 0; j < N_VISITS; j++) { + await PlacesTestUtils.addVisits(url); + } + } + + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length == N_TOP_SITES); + await changedPromise; + + // Ensure Top Sites is cached. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + N_TOP_SITES, + `The number of results should be the same as the number of Top Sites ${N_TOP_SITES}.` + ); + for (let i = 0; i < urls.length; i++) { + let { url } = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(url, urls[i], "The result url should be a Top Site."); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: SEARCH_STRING, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedSearchUrl + ); + Assert.equal( + gBrowser.selectedBrowser.searchTerms, + SEARCH_STRING, + "The search term should be in the Urlbar." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.notEqual( + details.url, + urls[0], + "The first result should not be a Top Site." + ); + Assert.equal( + details.heuristic, + true, + "The first result should be the heuristic result." + ); + Assert.equal( + details.url, + expectedSearchUrl, + "The first result url should be the same as the SERP." + ); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result be a search result." + ); + Assert.equal( + details.searchParams?.query, + SEARCH_STRING, + "The first result should have a matching query." + ); + + // Clean up. + SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_backgroundTabs.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_backgroundTabs.js new file mode 100644 index 0000000000..8ed29a9c5b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_backgroundTabs.js @@ -0,0 +1,63 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when search terms are +// expected to be shown and tabs are opened in the background. + +let defaultTestEngine; + +// The main search string used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +// If a user opens background tab search from the Urlbar, +// the search term should show when the tab is focused. +add_task(async function ctrl_open() { + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + // Search for the term in a new background tab. + let newTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedSearchUrl + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SEARCH_STRING, + fireInputEvent: true, + }); + gURLBar.focus(); + + EventUtils.synthesizeKey("KEY_Enter", { + altKey: true, + shiftKey: true, + }); + + // Find the background tab that was created, and switch to it. + let backgroundTab = await newTabPromise; + await BrowserTestUtils.switchTab(gBrowser, backgroundTab); + assertSearchStringIsInUrlbar(SEARCH_STRING); + + BrowserTestUtils.removeTab(backgroundTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_modifiedUrl.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_modifiedUrl.js new file mode 100644 index 0000000000..4c20864171 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_modifiedUrl.js @@ -0,0 +1,100 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when search terms are +// expected to be shown but the url is modified from what the browser expects. + +let defaultTestEngine; + +// The main search string used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +// If a SERP uses the History API to modify the URI, +// the search term should still show in the URL bar. +add_task(async function history_push_state() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + BrowserTestUtils.loadURIString(tab.linkedBrowser, expectedSearchUrl); + await browserLoadedPromise; + + let locationChangePromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let url = new URL(content.window.location); + url.searchParams.set("pc", "fake_code_2"); + content.history.pushState({}, "", url); + }); + + await locationChangePromise; + // Check URI to make sure that it's actually been changed + Assert.equal( + gBrowser.currentURI.spec, + `https://www.example.com/?q=chocolate+cake&pc=fake_code_2`, + "URI of Urlbar should have changed" + ); + + Assert.equal( + gURLBar.value, + SEARCH_STRING, + `Search string ${SEARCH_STRING} should be in the url bar` + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Loading a url that looks like a search query url but has additional +// query params should not show the search term in the Urlbar. +add_task(async function url_with_additional_query_params() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + // Add a query param + expectedSearchUrl += "&another_code=something_else"; + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + BrowserTestUtils.loadURIString(tab.linkedBrowser, expectedSearchUrl); + await browserLoadedPromise; + + Assert.equal(gURLBar.value, expectedSearchUrl, `URL should be in URL bar`); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Pageproxystate should be valid" + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_moveTab.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_moveTab.js new file mode 100644 index 0000000000..59f0eca916 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_moveTab.js @@ -0,0 +1,133 @@ +/* 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/. */ + +/* + These tests check the behavior of the Urlbar when search terms are shown + and the tab with the default SERP moves from one window to another. + + Unlike other searchTerm tests, these modify the currentURI to ensure + that the currentURI has a different spec than the default SERP so that + the search terms won't show if the originalURI wasn't properly copied + during the tab swap. +*/ + +let originalEngine, defaultTestEngine; + +// The main search keyword used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + originalEngine = await Services.search.getDefault(); + await Services.search.setDefault( + defaultTestEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + registerCleanupFunction(async function () { + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await PlacesUtils.history.clear(); + }); +}); + +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + return { tab, expectedSearchUrl }; +} + +// Move a tab showing the search term into its own window. +add_task(async function move_tab_into_new_window() { + let { tab, expectedSearchUrl } = await searchWithTab(SEARCH_STRING); + + // Mock the default SERP modifying the existing url + // so that the originalURI and currentURI differ. + await SpecialPowers.spawn( + tab.linkedBrowser, + [expectedSearchUrl], + async url => { + content.history.pushState({}, "", url + "&pc2=firefox"); + } + ); + + // Move the tab into its own window. + let newWindow = gBrowser.replaceTabWithWindow(tab); + await BrowserTestUtils.waitForEvent(tab.linkedBrowser, "SwapDocShells"); + + assertSearchStringIsInUrlbar(SEARCH_STRING, { win: newWindow }); + + // Clean up. + await BrowserTestUtils.closeWindow(newWindow); +}); + +// Move a tab from its own window into an existing window. +add_task(async function move_tab_into_existing_window() { + // Load a second window with the default SERP. + let win = await BrowserTestUtils.openNewBrowserWindow({ remote: true }); + let browser = win.gBrowser.selectedBrowser; + let tab = win.gBrowser.tabs[0]; + + // Load the default SERP into the second window. + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + browser, + false, + expectedSearchUrl + ); + BrowserTestUtils.loadURIString(browser, expectedSearchUrl); + await browserLoadedPromise; + + // Mock the default SERP modifying the existing url + // so that the originalURI and currentURI differ. + await SpecialPowers.spawn(browser, [expectedSearchUrl], async url => { + content.history.pushState({}, "", url + "&pc2=firefox"); + }); + + // Make the first window adopt and switch to that tab. + tab = gBrowser.adoptTab(tab); + await BrowserTestUtils.switchTab(gBrowser, tab); + assertSearchStringIsInUrlbar(SEARCH_STRING); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_popup.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_popup.js new file mode 100644 index 0000000000..d25e17d960 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_popup.js @@ -0,0 +1,145 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when persist search terms +// are either enabled or disabled, and a popup notification is shown. + +function waitForPopupNotification() { + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup." + ); + return promisePopupShown; +} + +// The main search string used in tests. +const SEARCH_TERM = "chocolate"; +const PREF_FEATUREGATE = "browser.urlbar.showSearchTerms.featureGate"; +let defaultTestEngine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_FEATUREGATE, true]], + }); + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine, + expectedPersistedSearchTerms = true +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + if (expectedPersistedSearchTerms) { + assertSearchStringIsInUrlbar(searchString); + } + + return { tab, expectedSearchUrl }; +} + +// A notification should cause the urlbar to revert while +// the search term persists. +add_task(async function generic_popup_when_persist_is_enabled() { + let { tab, expectedSearchUrl } = await searchWithTab(SEARCH_TERM); + + await waitForPopupNotification(); + + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Urlbar should have a valid pageproxystate." + ); + + Assert.equal( + gURLBar.value, + expectedSearchUrl, + "Search url should be in the urlbar." + ); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Ensure the urlbar is not being reverted when a prompt is shown +// and the persist feature is disabled. +add_task(async function generic_popup_no_revert_when_persist_is_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_FEATUREGATE, false]], + }); + + let { tab } = await searchWithTab( + SEARCH_TERM, + null, + defaultTestEngine, + false + ); + + // Have a user typed value in the urlbar to make + // pageproxystate invalid. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SEARCH_TERM, + }); + gURLBar.blur(); + + await waitForPopupNotification(); + + // Wait a brief amount of time between when the popup is shown + // and when the event handler should fire if it's enabled. + await TestUtils.waitForTick(); + + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Urlbar should not be reverted." + ); + + Assert.equal( + gURLBar.value, + SEARCH_TERM, + "User typed value should remain in urlbar." + ); + + BrowserTestUtils.removeTab(tab); + SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_revert.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_revert.js new file mode 100644 index 0000000000..91d6ea403a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_revert.js @@ -0,0 +1,170 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when search terms are shown +// and the user reverts the Urlbar. + +let defaultTestEngine; + +// The main search keyword used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + assertSearchStringIsInUrlbar(searchString); + + return { tab, expectedSearchUrl }; +} + +function synthesizeRevert() { + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Escape", { repeat: 2 }); +} + +// Users should be able to revert the URL bar +add_task(async function revert() { + let { tab, expectedSearchUrl } = await searchWithTab(SEARCH_STRING); + synthesizeRevert(); + + Assert.equal( + gURLBar.value, + expectedSearchUrl, + `Urlbar should have the reverted URI ${expectedSearchUrl} as its value.` + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Users should be able to revert the URL bar, +// and go to the same page. +add_task(async function revert_and_press_enter() { + let { tab, expectedSearchUrl } = await searchWithTab(SEARCH_STRING); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + synthesizeRevert(); + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + BrowserTestUtils.removeTab(tab); +}); + +// Users should be able to revert the URL, and then if they navigate +// to another tab, the tab that was reverted will show the search term again. +add_task(async function revert_and_change_tab() { + let { tab, expectedSearchUrl } = await searchWithTab(SEARCH_STRING); + + synthesizeRevert(); + + Assert.notEqual( + gURLBar.value, + SEARCH_STRING, + `Search string ${SEARCH_STRING} should not be in the url bar` + ); + Assert.equal( + gURLBar.value, + expectedSearchUrl, + `Urlbar should have ${expectedSearchUrl} as value.` + ); + + // Open another tab + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Switch back to the original tab. + await BrowserTestUtils.switchTab(gBrowser, tab); + + // Because the urlbar is focused, the pageproxystate should be invalid. + assertSearchStringIsInUrlbar(SEARCH_STRING, { pageProxyState: "invalid" }); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// If a user reverts a tab, and then does another search, +// they should be able to see the search term again. +add_task(async function revert_and_search_again() { + let { tab } = await searchWithTab(SEARCH_STRING); + synthesizeRevert(); + await searchWithTab("another search string", tab); + BrowserTestUtils.removeTab(tab); +}); + +// If a user reverts the Urlbar while on a default SERP, +// and they navigate away from the page by visiting another +// link or using the back/forward buttons, the Urlbar should +// show the search term again when returning back to the default SERP. +add_task(async function revert_when_using_content() { + let { tab } = await searchWithTab(SEARCH_STRING); + synthesizeRevert(); + await searchWithTab("another search string", tab); + + // Revert the page, and then go back and forth in history. + // The search terms should show up. + synthesizeRevert(); + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + assertSearchStringIsInUrlbar(SEARCH_STRING); + + pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goForward(); + await pageShowPromise; + assertSearchStringIsInUrlbar("another search string"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchBar.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchBar.js new file mode 100644 index 0000000000..784d8932ac --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchBar.js @@ -0,0 +1,104 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when a user enables +// the search bar and showSearchTerms is true. + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); + +const gCUITestUtils = new CustomizableUITestUtils(window); +const SEARCH_STRING = "example_string"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.widget.inNavBar", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + gCUITestUtils.removeSearchBar(); + }); +}); + +function assertSearchStringIsNotInUrlbar(searchString) { + Assert.notEqual( + gURLBar.value, + searchString, + `Search string ${searchString} should not be in the url bar.` + ); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Pageproxystate should be valid." + ); + Assert.equal( + gBrowser.selectedBrowser.searchTerms, + "", + "searchTerms should be blank." + ); +} + +// When a user enables the search bar, and does a search in the search bar, +// the search term should not show in the URL bar. +add_task(async function search_bar_on() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await gCUITestUtils.addSearchBar(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + `https://www.example.com/?q=${SEARCH_STRING}&pc=fake_code` + ); + + let searchBar = BrowserSearch.searchBar; + searchBar.value = SEARCH_STRING; + searchBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + + await browserLoadedPromise; + assertSearchStringIsNotInUrlbar(SEARCH_STRING); + + BrowserTestUtils.removeTab(tab); +}); + +// When a user enables the search bar, and does a search in the URL bar, +// the search term should still not show in the URL bar. +add_task(async function search_bar_on_with_url_bar_search() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await gCUITestUtils.addSearchBar(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + `https://www.example.com/?q=${SEARCH_STRING}&pc=fake_code` + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: SEARCH_STRING, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + await browserLoadedPromise; + assertSearchStringIsNotInUrlbar(SEARCH_STRING); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchMode.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchMode.js new file mode 100644 index 0000000000..880b597784 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_searchMode.js @@ -0,0 +1,81 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when using search mode + +let defaultTestEngine; + +// The main search string used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + await SearchTestUtils.installSearchExtension( + { + name: "MochiSearch", + search_url: "https://mochi.test:8888/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +// When a user does a search with search mode, they should +// not see the search term in the URL bar for that engine. +add_task(async function non_default_search() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + defaultTestEngine, + SEARCH_STRING + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SEARCH_STRING, + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: defaultTestEngine.name, + }); + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + Assert.equal(gURLBar.value, expectedSearchUrl, `URL should be in URL bar`); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Pageproxystate should be valid" + ); + Assert.equal( + gBrowser.userTypedValue, + null, + "There should not be a userTypedValue for a search on a non-default search engine" + ); + Assert.equal( + gBrowser.selectedBrowser.searchTerms, + "", + "searchTerms should be empty." + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_switch_tab.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_switch_tab.js new file mode 100644 index 0000000000..77ee3e19d7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_switch_tab.js @@ -0,0 +1,139 @@ +/* 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/. */ + +// These tests check the behavior of the Urlbar when search terms are shown +// and the user switches between tabs. + +let defaultTestEngine; + +// The main search keyword used in tests +const SEARCH_STRING = "chocolate cake"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + assertSearchStringIsInUrlbar(searchString); + + return { tab, expectedSearchUrl }; +} + +// Users should be able to search, change the tab, and come +// back to the original tab to see the search term again +add_task(async function change_tab() { + let { tab: tab1 } = await searchWithTab(SEARCH_STRING); + let { tab: tab2 } = await searchWithTab("another keyword"); + let { tab: tab3 } = await searchWithTab("yet another keyword"); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + assertSearchStringIsInUrlbar(SEARCH_STRING); + + await BrowserTestUtils.switchTab(gBrowser, tab2); + assertSearchStringIsInUrlbar("another keyword"); + + await BrowserTestUtils.switchTab(gBrowser, tab3); + assertSearchStringIsInUrlbar("yet another keyword"); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); +}); + +// If a user types in the URL bar, and the user goes to a +// different tab, the original tab should still contain the +// text written by the user. +add_task(async function user_overwrites_search_term() { + let { tab: tab1 } = await searchWithTab(SEARCH_STRING); + + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendString("another_word"); + + Assert.notEqual( + gURLBar.value, + SEARCH_STRING, + `Search string ${SEARCH_STRING} should not be in the url bar` + ); + + // Open a new tab, switch back to the first and + // check that the user typed value is still there. + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + Assert.equal( + gURLBar.value, + "another_word", + "another_word should be in the url bar" + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +// If a user clears the URL bar, and goes to a different tab, +// and returns to the initial tab, it should show the search term again. +add_task(async function user_overwrites_search_term() { + let { tab: tab1 } = await searchWithTab(SEARCH_STRING); + + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendKey("delete"); + + Assert.equal(gURLBar.value, "", "Empty string should be in url bar."); + + // Open a new tab, switch back to the first and check + // the blank string is replaced with the search string. + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + assertSearchStringIsInUrlbar(SEARCH_STRING, { + pageProxyState: "invalid", + userTypedValue: "", + }); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_telemetry.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_telemetry.js new file mode 100644 index 0000000000..bdad68e0ef --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_searchTerms_telemetry.js @@ -0,0 +1,378 @@ +/* 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/. */ + +/** + * These tests check that we record the number of times search terms + * persist in the Urlbar, and when search terms are cleared due to a + * PopupNotification. + * + * This is different from existing telemetry that tracks whether users + * interacted with the Urlbar or made another search while the search + * terms were peristed. + */ + +let defaultTestEngine; + +// The main search string used in tests +const SEARCH_STRING = "chocolate cake"; + +// Telemetry keys. +const PERSISTED_VIEWED = "urlbar.persistedsearchterms.view_count"; +const PERSISTED_REVERTED = "urlbar.persistedsearchterms.revert_by_popup_count"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + defaultTestEngine = Services.search.getEngineByName("MozSearch"); + Services.telemetry.clearScalars(); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.clearScalars(); + }); +}); + +// Starts a search with a tab and asserts that +// the state of the Urlbar contains the search term. +async function searchWithTab( + searchString, + tab = null, + engine = defaultTestEngine, + assertSearchString = true +) { + if (!tab) { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + } + + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl(engine, searchString); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + + if (assertSearchString) { + assertSearchStringIsInUrlbar(searchString); + } + + return { tab, expectedSearchUrl }; +} + +add_task(async function load_page_with_persisted_search() { + let { tab } = await searchWithTab(SEARCH_STRING); + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function load_page_without_persisted_search() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + + let { tab } = await searchWithTab( + SEARCH_STRING, + null, + defaultTestEngine, + false + ); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, undefined); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Multiple searches should result in the correct number of recorded views. +add_task(async function load_page_n_times() { + let N = 5; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + for (let index = 0; index < N; ++index) { + await searchWithTab(SEARCH_STRING, tab); + } + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, N); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +// A persisted search view event should not be recorded when unfocusing the urlbar. +add_task(async function focus_and_unfocus() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + gURLBar.focus(); + gURLBar.select(); + gURLBar.blur(); + + // Focusing and unfocusing the urlbar shouldn't change the persisted view count. + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +// A persisted search view event should not be recorded by a +// pushState event after a page has been loaded. +add_task(async function history_api() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let url = new URL(content.window.location); + let someState = { value: true }; + url.searchParams.set("pc", "fake_code_2"); + content.history.pushState(someState, "", url); + someState.value = false; + content.history.replaceState(someState, "", url); + }); + + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +// A persisted search view event should be recorded when switching back to a tab +// that contains search terms. +add_task(async function switch_tabs() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab); + + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 2); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// A telemetry event should be recorded when returning to a persisted SERP via tabhistory. +add_task(async function tabhistory() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "https://www.example.com/some_url" + ); + await browserLoadedPromise; + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 2); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +// PopupNotification's that rely on an anchor element in the urlbar should trigger +// an increment of the revert counter. +// This assumes the anchor element is present in the Urlbar during a valid pageproxystate. +add_task(async function popup_in_urlbar() { + let { tab } = await searchWithTab(SEARCH_STRING); + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup." + ); + await promisePopupShown; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, 1); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +// Non-persistent PopupNotifications won't re-appear if a user switches +// tabs and returns to the tab that had the Popup. +add_task(async function non_persistent_popup_in_urlbar_switch_tab() { + let { tab } = await searchWithTab(SEARCH_STRING); + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup.", + "geo-notification-icon" + ); + await promisePopupShown; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, 1); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab); + + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 2); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, 1); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Persistent PopupNotifications will constantly appear to users +// even if they switch to another tab and switch back. +add_task(async function persistent_popup_in_urlbar_switch_tab() { + let { tab } = await searchWithTab(SEARCH_STRING); + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup.", + "geo-notification-icon", + null, + null, + { persistent: true } + ); + await promisePopupShown; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, 1); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + await BrowserTestUtils.switchTab(gBrowser, tab); + await promisePopupShown; + + scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 2); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, 2); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// If the persist feature is not enabled, a telemetry event should not be recorded +// if a PopupNotification uses an anchor in the Urlbar. +add_task(async function popup_in_urlbar_without_feature() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + + let { tab } = await searchWithTab( + SEARCH_STRING, + null, + defaultTestEngine, + false + ); + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup." + ); + await promisePopupShown; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, undefined); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// If the anchor element for the PopupNotification is not located in the Urlbar, +// a telemetry event should not be recorded. +add_task(async function popup_not_in_urlbar() { + let { tab } = await searchWithTab(SEARCH_STRING); + + let promisePopupShown = BrowserTestUtils.waitForEvent( + PopupNotifications.panel, + "popupshown" + ); + PopupNotifications.show( + gBrowser.selectedBrowser, + "test-notification", + "This is a sample popup that uses the unified extensions button.", + gUnifiedExtensions.getPopupAnchorID(gBrowser.selectedBrowser, window) + ); + await promisePopupShown; + + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_VIEWED, 1); + TelemetryTestUtils.assertScalar(scalars, PERSISTED_REVERTED, undefined); + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.js new file mode 100644 index 0000000000..0fa365f8bc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.js @@ -0,0 +1,128 @@ +/* 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/. */ + +function test() { + waitForExplicitFinish(); + + // avoid prompting about phishing + Services.prefs.setIntPref(phishyUserPassPref, 32); + registerCleanupFunction(function () { + Services.prefs.clearUserPref(phishyUserPassPref); + }); + + nextTest(); +} + +const phishyUserPassPref = "network.http.phishy-userpass-length"; + +function nextTest() { + let testCase = tests.shift(); + if (testCase) { + testCase(function () { + executeSoon(nextTest); + }); + } else { + executeSoon(finish); + } +} + +var tests = [ + function revert(next) { + loadTabInWindow(window, function (tab) { + gURLBar.handleRevert(); + is( + gURLBar.value, + "example.com", + "URL bar had user/pass stripped after reverting" + ); + gBrowser.removeTab(tab); + next(); + }); + }, + function customize(next) { + // Need to wait for delayedStartup for the customization part of the test, + // since that's where BrowserToolboxCustomizeDone is set. + BrowserTestUtils.openNewBrowserWindow().then(function (win) { + loadTabInWindow(win, function () { + openToolbarCustomizationUI(function () { + closeToolbarCustomizationUI(function () { + is( + win.gURLBar.value, + "example.com", + "URL bar had user/pass stripped after customize" + ); + win.close(); + next(); + }, win); + }, win); + }); + }); + }, + function pageloaderror(next) { + loadTabInWindow(window, function (tab) { + // Load a new URL and then immediately stop it, to simulate a page load + // error. + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "http://test1.example.com" + ); + tab.linkedBrowser.stop(); + is( + gURLBar.value, + "example.com", + "URL bar had user/pass stripped after load error" + ); + gBrowser.removeTab(tab); + next(); + }); + }, +]; + +function loadTabInWindow(win, callback) { + info("Loading tab"); + let url = "http://user:pass@example.com/"; + let tab = (win.gBrowser.selectedTab = BrowserTestUtils.addTab( + win.gBrowser, + url + )); + BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url).then(() => { + info("Tab loaded"); + is( + win.gURLBar.value, + "example.com", + "URL bar had user/pass stripped initially" + ); + callback(tab); + }, true); +} + +function openToolbarCustomizationUI(aCallback, aBrowserWin) { + if (!aBrowserWin) { + aBrowserWin = window; + } + + aBrowserWin.gCustomizeMode.enter(); + + aBrowserWin.gNavToolbox.addEventListener( + "customizationready", + function () { + executeSoon(function () { + aCallback(aBrowserWin); + }); + }, + { once: true } + ); +} + +function closeToolbarCustomizationUI(aCallback, aBrowserWin) { + aBrowserWin.gNavToolbox.addEventListener( + "aftercustomization", + function () { + executeSoon(aCallback); + }, + { once: true } + ); + + aBrowserWin.gCustomizeMode.exit(); +} diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_tooltip.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_tooltip.js new file mode 100644 index 0000000000..5ecb5e7a90 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_tooltip.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function synthesizeMouseOver(element) { + info("synthesize mouseover"); + let promise = BrowserTestUtils.waitForEvent(element, "mouseover"); + EventUtils.synthesizeMouseAtCenter(document.documentElement, { + type: "mouseout", + }); + EventUtils.synthesizeMouseAtCenter(element, { type: "mouseover" }); + EventUtils.synthesizeMouseAtCenter(element, { type: "mousemove" }); + return promise; +} + +function synthesizeMouseOut(element) { + info("synthesize mouseout"); + let promise = BrowserTestUtils.waitForEvent(element, "mouseout"); + EventUtils.synthesizeMouseAtCenter(element, { type: "mouseover" }); + EventUtils.synthesizeMouseAtCenter(element, { type: "mouseout" }); + EventUtils.synthesizeMouseAtCenter(document.documentElement, { + type: "mousemove", + }); + return promise; +} + +async function expectTooltip(text) { + if (!gURLBar._overflowing && !gURLBar._inOverflow) { + info("waiting for overflow event"); + await BrowserTestUtils.waitForEvent(gURLBar.inputField, "overflow"); + } + + let tooltip = document.getElementById("aHTMLTooltip"); + let element = gURLBar.inputField; + + let popupShownPromise = BrowserTestUtils.waitForEvent(tooltip, "popupshown"); + await synthesizeMouseOver(element); + info("awaiting for tooltip popup"); + await popupShownPromise; + + is(element.getAttribute("title"), text, "title attribute has expected text"); + is(tooltip.textContent, text, "tooltip shows expected text"); + + await synthesizeMouseOut(element); +} + +async function expectNoTooltip() { + if (gURLBar._overflowing || gURLBar._inOverflow) { + info("waiting for underflow event"); + await BrowserTestUtils.waitForEvent(gURLBar.inputField, "underflow"); + } + + let element = gURLBar.inputField; + await synthesizeMouseOver(element); + + is(element.getAttribute("title"), null, "title attribute shouldn't be set"); + + await synthesizeMouseOut(element); +} + +add_task(async function () { + window.windowUtils.disableNonTestMouseEvents(true); + registerCleanupFunction(() => { + window.windowUtils.disableNonTestMouseEvents(false); + }); + + // Ensure the URL bar is neither focused nor hovered before we start. + gBrowser.selectedBrowser.focus(); + await synthesizeMouseOut(gURLBar.inputField); + + gURLBar.value = "short string"; + await expectNoTooltip(); + + let longURL = "http://longurl.com/" + "foobar/".repeat(30); + gURLBar.value = longURL; + is( + gURLBar.inputField.value, + longURL.replace(/^http:\/\//, ""), + "Urlbar value has http:// stripped" + ); + await expectTooltip(longURL); +}); diff --git a/browser/components/urlbar/tests/browser/browser_UrlbarInput_trimURLs.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_trimURLs.js new file mode 100644 index 0000000000..d1bd46f022 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_trimURLs.js @@ -0,0 +1,127 @@ +add_task(async function () { + const PREF_TRIMURLS = "browser.urlbar.trimURLs"; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + Services.prefs.clearUserPref(PREF_TRIMURLS); + gURLBar.setURI(); + }); + + // Avoid search service sync init warnings due to URIFixup, when running the + // test alone. + await Services.search.init(); + + Services.prefs.setBoolPref(PREF_TRIMURLS, true); + + testVal("http://mozilla.org/", "mozilla.org"); + testVal("https://mozilla.org/", "https://mozilla.org"); + testVal("http://mözilla.org/", "mözilla.org"); + // This isn't a valid public suffix, thus we should untrim it or it would + // end up doing a search. + testVal("http://mozilla.imaginatory/"); + testVal("http://www.mozilla.org/", "www.mozilla.org"); + testVal("http://sub.mozilla.org/", "sub.mozilla.org"); + testVal("http://sub1.sub2.sub3.mozilla.org/", "sub1.sub2.sub3.mozilla.org"); + testVal("http://mozilla.org/file.ext", "mozilla.org/file.ext"); + testVal("http://mozilla.org/sub/", "mozilla.org/sub/"); + + testVal("http://ftp.mozilla.org/", "ftp.mozilla.org"); + testVal("http://ftp1.mozilla.org/", "ftp1.mozilla.org"); + testVal("http://ftp42.mozilla.org/", "ftp42.mozilla.org"); + testVal("http://ftpx.mozilla.org/", "ftpx.mozilla.org"); + testVal("ftp://ftp.mozilla.org/", "ftp://ftp.mozilla.org"); + testVal("ftp://ftp1.mozilla.org/", "ftp://ftp1.mozilla.org"); + testVal("ftp://ftp42.mozilla.org/", "ftp://ftp42.mozilla.org"); + testVal("ftp://ftpx.mozilla.org/", "ftp://ftpx.mozilla.org"); + + testVal("https://user:pass@mozilla.org/", "https://user:pass@mozilla.org"); + testVal("https://user@mozilla.org/", "https://user@mozilla.org"); + testVal("http://user:pass@mozilla.org/", "user:pass@mozilla.org"); + testVal("http://user@mozilla.org/", "user@mozilla.org"); + testVal("http://sub.mozilla.org:666/", "sub.mozilla.org:666"); + + testVal("https://[fe80::222:19ff:fe11:8c76]/file.ext"); + testVal("http://[fe80::222:19ff:fe11:8c76]/", "[fe80::222:19ff:fe11:8c76]"); + testVal("https://user:pass@[fe80::222:19ff:fe11:8c76]:666/file.ext"); + testVal( + "http://user:pass@[fe80::222:19ff:fe11:8c76]:666/file.ext", + "user:pass@[fe80::222:19ff:fe11:8c76]:666/file.ext" + ); + + testVal("mailto:admin@mozilla.org"); + testVal("gopher://mozilla.org/"); + testVal("about:config"); + testVal("jar:http://mozilla.org/example.jar!/"); + testVal("view-source:http://mozilla.org/"); + + // Behaviour for hosts with no dots depends on the whitelist: + let fixupWhitelistPref = "browser.fixup.domainwhitelist.localhost"; + Services.prefs.setBoolPref(fixupWhitelistPref, false); + testVal("http://localhost"); + Services.prefs.setBoolPref(fixupWhitelistPref, true); + testVal("http://localhost", "localhost"); + Services.prefs.clearUserPref(fixupWhitelistPref); + + testVal("http:// invalid url"); + + testVal("http://someotherhostwithnodots"); + + // This host is whitelisted, it can be trimmed. + testVal("http://localhost/ foo bar baz", "localhost/ foo bar baz"); + + // This is not trimmed because it's not in the domain whitelist. + testVal( + "http://localhost.localdomain/ foo bar baz", + "http://localhost.localdomain/ foo bar baz" + ); + + Services.prefs.setBoolPref(PREF_TRIMURLS, false); + + testVal("http://mozilla.org/"); + + Services.prefs.setBoolPref(PREF_TRIMURLS, true); + + let promiseLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://example.com/" + ); + BrowserTestUtils.loadURIString(gBrowser, "http://example.com/"); + await promiseLoaded; + + await testCopy("example.com", "http://example.com/"); + + gURLBar.setPageProxyState("invalid"); + gURLBar.valueIsTyped = true; + await testCopy("example.com", "example.com"); +}); + +function testVal(originalValue, targetValue) { + gURLBar.value = originalValue; + gURLBar.valueIsTyped = false; + let trimmedValue = UrlbarPrefs.get("trimURLs") + ? BrowserUIUtils.trimURL(originalValue) + : originalValue; + Assert.equal(gURLBar.value, trimmedValue, "url bar value set"); + // Now focus the urlbar and check the inputField value is properly set. + gURLBar.focus(); + Assert.equal( + gURLBar.value, + targetValue || originalValue, + "Check urlbar value on focus" + ); + // On blur we should trim again. + gURLBar.blur(); + Assert.equal(gURLBar.value, trimmedValue, "Check urlbar value on blur"); +} + +function testCopy(originalValue, targetValue) { + return SimpleTest.promiseClipboardChange(targetValue, () => { + Assert.equal(gURLBar.value, originalValue, "url bar copy value set"); + gURLBar.focus(); + gURLBar.select(); + goDoCommand("cmd_copy"); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js b/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js new file mode 100644 index 0000000000..1bb65c0c42 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js @@ -0,0 +1,196 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests ensures the urlbar is cleared properly when about:home is visited. + */ + +"use strict"; + +const { SessionSaver } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionSaver.sys.mjs" +); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); + +add_task(function addHomeButton() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +/** + * Test what happens if loading a URL that should clear the + * location bar after a parent process URL. + */ +add_task(async function clearURLBarAfterParentProcessURL() { + let tab = await new Promise(resolve => { + gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:preferences" + ); + let newTabBrowser = gBrowser.getBrowserForTab(gBrowser.selectedTab); + newTabBrowser.addEventListener( + "Initialized", + async function () { + resolve(gBrowser.selectedTab); + }, + { capture: true, once: true } + ); + }); + document.getElementById("home-button").click(); + await BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + HomePage.get() + ); + is(gURLBar.value, "", "URL bar should be empty"); + is( + tab.linkedBrowser.userTypedValue, + null, + "The browser should have no recorded userTypedValue" + ); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Same as above, but open the tab without passing the URL immediately + * which changes behaviour in tabbrowser.xml. + */ +add_task(async function clearURLBarAfterParentProcessURLInExistingTab() { + let tab = await new Promise(resolve => { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + let newTabBrowser = gBrowser.getBrowserForTab(gBrowser.selectedTab); + newTabBrowser.addEventListener( + "Initialized", + async function () { + resolve(gBrowser.selectedTab); + }, + { capture: true, once: true } + ); + BrowserTestUtils.loadURIString(newTabBrowser, "about:preferences"); + }); + document.getElementById("home-button").click(); + await BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + HomePage.get() + ); + is(gURLBar.value, "", "URL bar should be empty"); + is( + tab.linkedBrowser.userTypedValue, + null, + "The browser should have no recorded userTypedValue" + ); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Load about:home directly from an about:newtab page. Because it is an + * 'initial' page, we need to treat this specially if the user actually + * loads a page like this from the URL bar. + */ +add_task(async function clearURLBarAfterManuallyLoadingAboutHome() { + let promiseTabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + gBrowser, + () => {} + ); + // This opens about:newtab: + BrowserOpenTab(); + let tab = await promiseTabOpenedAndSwitchedTo; + is(gURLBar.value, "", "URL bar should be empty"); + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + + gURLBar.value = "about:home"; + gURLBar.select(); + let aboutHomeLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:home" + ); + EventUtils.sendKey("return"); + await aboutHomeLoaded; + + is(gURLBar.value, "", "URL bar should be empty"); + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Ensure we don't show 'about:home' in the URL bar temporarily in new tabs + * while we're switching remoteness (when the URL we're loading and the + * default content principal are different). + */ +add_task(async function dontTemporarilyShowAboutHome() { + requestLongerTimeout(2); + + await SpecialPowers.pushPrefEnv({ set: [["browser.startup.page", 1]] }); + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow(); + await windowOpenedPromise; + let promiseTabSwitch = BrowserTestUtils.switchTab(win.gBrowser, () => {}); + win.BrowserOpenTab(); + await promiseTabSwitch; + is(win.gBrowser.visibleTabs.length, 2, "2 tabs opened"); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + await BrowserTestUtils.closeWindow(win); + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await SessionSaver.run(); + + windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + win = SessionStore.undoCloseWindow(0); + await windowOpenedPromise; + let wpl = { + onLocationChange() { + is(win.gURLBar.value, "", "URL bar value should stay empty."); + }, + }; + win.gBrowser.addProgressListener(wpl); + + if (win.gBrowser.visibleTabs.length < 2) { + await BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "TabOpen"); + } + let otherTab = win.gBrowser.selectedTab.previousElementSibling; + let tabLoaded = BrowserTestUtils.browserLoaded( + otherTab.linkedBrowser, + false, + "about:home" + ); + await BrowserTestUtils.switchTab(win.gBrowser, otherTab); + await tabLoaded; + win.gBrowser.removeProgressListener(wpl); + is(win.gURLBar.value, "", "URL bar value should be empty."); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Test that if the Home Button is clicked after a user has typed + * some value into the URL bar, that the URL bar is cleared if + * the homepage is one of the initial pages set. + */ +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + url: "http://example.com", + gBrowser, + }, + async browser => { + const TYPED_VALUE = "This string should get cleared"; + gURLBar.value = TYPED_VALUE; + browser.userTypedValue = TYPED_VALUE; + + document.getElementById("home-button").click(); + await BrowserTestUtils.browserLoaded(browser, false, HomePage.get()); + is(gURLBar.value, "", "URL bar should be empty"); + is( + browser.userTypedValue, + null, + "The browser should have no recorded userTypedValue" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js b/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js new file mode 100644 index 0000000000..cc1ed29ceb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_acknowledgeFeedbackAndDismissal.js @@ -0,0 +1,304 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests feedback and dismissal acknowledgments in the view. + */ + +"use strict"; + +// The name of this command must be one that's recognized as not ending the +// urlbar session. See `isSessionOngoing` comments for details. +const FEEDBACK_COMMAND = "show_less_frequently"; + +let gTestProvider; + +add_setup(async function () { + gTestProvider = new TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: "https://example.com/", + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + } + ), + ], + }); + + gTestProvider.commandCount = {}; + UrlbarProvidersManager.registerProvider(gTestProvider); + + // Add a visit so that there's one result above the test result (the + // heuristic) and one below (the visit) just to make sure removing the test + // result doesn't mess up adjacent results. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + await PlacesTestUtils.addVisits("https://example.com/aaa"); + + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(gTestProvider); + }); +}); + +// Tests dismissal acknowledgment when the dismissed row is not selected. +add_task(async function acknowledgeDismissal_rowNotSelected() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await doDismissTest({ shouldBeSelected: false }); +}); + +// Tests dismissal acknowledgment when the dismissed row is selected. +add_task(async function acknowledgeDismissal_rowSelected() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + // Select the row. + let resultIndex = await getTestResultIndex(); + while (gURLBar.view.selectedRowIndex != resultIndex) { + this.EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + await doDismissTest({ resultIndex, shouldBeSelected: true }); +}); + +// Tests a feedback acknowledgment command immediately followed by a dismissal +// acknowledgment command. This makes sure that both feedback acknowledgment +// works and a subsequent dismissal command works while the urlbar session +// remains ongoing. +add_task(async function acknowledgeFeedbackAndDismissal() { + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + let resultIndex = await getTestResultIndex(); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + + // Click the feedback command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, FEEDBACK_COMMAND, { + resultIndex, + }); + + Assert.equal( + gTestProvider.commandCount[FEEDBACK_COMMAND], + 1, + "One feedback command should have happened" + ); + gTestProvider.commandCount[FEEDBACK_COMMAND] = 0; + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + + info("Doing dismissal"); + await doDismissTest({ resultIndex, shouldBeSelected: true }); +}); + +/** + * Does a dismissal test: + * + * 1. Clicks the dismiss command in the test result + * 2. Verifies a dismissal acknowledgment tip replaces the result + * 3. Clicks the "Got it" button in the tip + * 4. Verifies the tip is dismissed + * + * @param {object} options + * Options object + * @param {boolean} options.shouldBeSelected + * True if the test result is expected to be selected initially. If true, this + * function verifies the "Got it" button in the dismissal acknowledgment tip + * also becomes selected. + * @param {number} options.resultIndex + * The index of the test result, if known beforehand. Leave -1 to find it + * automatically. + */ +async function doDismissTest({ shouldBeSelected, resultIndex = -1 }) { + if (resultIndex < 0) { + resultIndex = await getTestResultIndex(); + } + + let selectedElement = gURLBar.view.selectedElement; + Assert.ok(selectedElement, "There should be an initially selected element"); + + if (shouldBeSelected) { + Assert.equal( + gURLBar.view.selectedRowIndex, + resultIndex, + "The test result should be selected" + ); + } else { + Assert.notEqual( + gURLBar.view.selectedRowIndex, + resultIndex, + "The test result should not be selected" + ); + } + + let resultCount = UrlbarTestUtils.getResultCount(window); + + // Click the dismiss command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, "dismiss", { + resultIndex, + openByMouse: true, + }); + + Assert.equal( + gTestProvider.commandCount.dismiss, + 1, + "One dismissal should have happened" + ); + gTestProvider.commandCount.dismiss = 0; + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("selected"), + "Row should not have 'selected' attribute" + ); + Assert.ok( + !details.element.row._content.hasAttribute("selected"), + "Row-inner should not have 'selected' attribute" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + "0", + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + + if (shouldBeSelected) { + Assert.equal( + gURLBar.view.selectedElement, + gotItButton, + "The 'Got it' button should be selected" + ); + } else { + Assert.notEqual( + gURLBar.view.selectedElement, + gotItButton, + "The 'Got it' button should not be selected" + ); + Assert.equal( + gURLBar.view.selectedElement, + selectedElement, + "The initially selected element should remain selected" + ); + } + + // Click it. + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.providerName != gTestProvider.name, + "Tip result and test result should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +} + +/** + * A provider that acknowledges feedback and dismissals. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + getResultCommands(result) { + return [ + { + name: FEEDBACK_COMMAND, + l10n: { + id: "firefox-suggest-weather-command-inaccurate-location", + }, + }, + { + name: "dismiss", + l10n: { + id: "firefox-suggest-weather-command-not-interested", + }, + }, + ]; + } + + onEngagement(isPrivate, state, queryContext, details) { + if (details.result?.providerName == this.name) { + let { selType } = details; + + info(`onEngagement called, selType=` + selType); + + if (!this.commandCount.hasOwnProperty(selType)) { + this.commandCount[selType] = 0; + } + this.commandCount[selType]++; + + if (selType == FEEDBACK_COMMAND) { + queryContext.view.acknowledgeFeedback(details.result); + } else if (selType == "dismiss") { + queryContext.view.acknowledgeDismissal(details.result); + } + } + } +} + +async function getTestResultIndex() { + let index = 0; + let resultCount = UrlbarTestUtils.getResultCount(window); + for (; index < resultCount; index++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if (details.result.providerName == gTestProvider.name) { + break; + } + } + Assert.less(index, resultCount, "The test result should be present"); + return index; +} diff --git a/browser/components/urlbar/tests/browser/browser_action_searchengine.js b/browser/components/urlbar/tests/browser/browser_action_searchengine.js new file mode 100644 index 0000000000..2520315fa2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_action_searchengine.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that a search result has the correct attributes and visits the + * expected URL for the engine. + */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", false], + ], + }); + + await SearchTestUtils.installSearchExtension( + { name: "MozSearch" }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearchPrivate", + search_url: "https://example.com/private", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +async function testSearch(win, expectedName, expectedBaseUrl) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "open a search", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have type search" + ); + Assert.deepEqual( + result.searchParams, + { + engine: expectedName, + keyword: undefined, + query: "open a search", + suggestion: undefined, + inPrivateWindow: undefined, + isPrivateEngine: undefined, + }, + "Should have the correct result parameters." + ); + + Assert.equal( + result.image, + UrlbarUtils.ICON.SEARCH_GLASS, + "Should have the search icon image" + ); + + let tabPromise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await tabPromise; + + Assert.equal( + win.gBrowser.selectedBrowser.currentURI.spec, + expectedBaseUrl + "?q=open+a+search", + "Should have loaded the correct page" + ); +} + +add_task(async function test_search_normal_window() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + registerCleanupFunction(async function () { + try { + BrowserTestUtils.removeTab(tab); + } catch (ex) { + /* tab may have already been closed in case of failure */ + } + }); + + await testSearch(window, "MozSearch", "https://example.com/"); +}); + +add_task(async function test_search_private_window_no_separate_default() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + registerCleanupFunction(async function () { + await BrowserTestUtils.closeWindow(win); + }); + + await testSearch(win, "MozSearch", "https://example.com/"); +}); + +add_task(async function test_search_private_window() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault", true]], + }); + + let engine = Services.search.getEngineByName("MozSearchPrivate"); + let originalEngine = await Services.search.getDefaultPrivate(); + await Services.search.setDefaultPrivate( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + await Services.search.setDefaultPrivate( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await testSearch(win, "MozSearchPrivate", "https://example.com/private"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_action_searchengine_alias.js b/browser/components/urlbar/tests/browser/browser_action_searchengine_alias.js new file mode 100644 index 0000000000..b79c324a04 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_action_searchengine_alias.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search result obtained using a search keyword gives an entry with + * the correct attributes and visits the expected URL for the engine. + */ + +add_task(async function () { + await SearchTestUtils.installSearchExtension( + { keyword: "moz" }, + { setAsDefault: true } + ); + let engine = Services.search.getEngineByName("Example"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + // Disable autofill so mozilla.org isn't autofilled below. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + registerCleanupFunction(async function () { + try { + BrowserTestUtils.removeTab(tab); + } catch (ex) { + /* tab may have already been closed in case of failure */ + } + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "moz", + }); + Assert.equal(gURLBar.value, "moz", "Value should be unchanged"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "moz open a search", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "typed", + }); + Assert.equal(gURLBar.value, "open a search", "value should be query"); + + let tabPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await tabPromise; + + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + "https://example.com/?q=open+a+search", + "Should have loaded the correct page" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_add_search_engine.js b/browser/components/urlbar/tests/browser/browser_add_search_engine.js new file mode 100644 index 0000000000..f016aab3e7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_add_search_engine.js @@ -0,0 +1,325 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding engines through the Address Bar context menu. + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/"; + +add_task(async function context_none() { + info("Checks the context menu with a page that doesn't offer any engines."); + let url = "http://mochi.test:8888/"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, popup => { + info("The separator and the add engine item should not be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(!!elt); + Assert.ok(!BrowserTestUtils.is_visible(elt)); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-0")); + }); + }); +}); + +add_task(async function context_one() { + info("Checks the context menu with a page that offers one engine."); + let url = getRootDirectory(gTestPath) + "add_search_engine_one.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + Assert.ok(elt.hasAttribute("image")); + Assert.equal( + elt.getAttribute("uri"), + BASE_URL + "add_search_engine_0.xml" + ); + + info("Click on the menuitem"); + let enginePromise = promiseEngine("engine-added", "add_search_engine_0"); + popup.activateItem(elt); + await enginePromise; + Assert.equal(popup.state, "closed"); + }); + + await UrlbarTestUtils.withContextMenu(window, popup => { + info("The separator and the add engine item should not be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(!BrowserTestUtils.is_visible(elt)); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-0")); + }); + + info("Remove the engine."); + let engine = await Services.search.getEngineByName("add_search_engine_0"); + await Services.search.removeEngine(engine); + + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present again."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + }); + }); +}); + +add_task(async function context_invalid() { + info("Checks the context menu with a page that offers an invalid engine."); + await SpecialPowers.pushPrefEnv({ + set: [["prompts.contentPromptSubDialog", false]], + }); + + let url = getRootDirectory(gTestPath) + "add_search_engine_invalid.html"; + await BrowserTestUtils.withNewTab(url, async tab => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + Assert.ok(popup.parentNode.getMenuItem("add-engine-separator")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + let elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_404")); + Assert.equal( + elt.getAttribute("uri"), + BASE_URL + "add_search_engine_404.xml" + ); + + info("Click on the menuitem"); + let promptPromise = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType: Ci.nsIPromptService.MODAL_TYPE_CONTENT, + promptType: "alert", + }); + + popup.activateItem(elt); + + let prompt = await promptPromise; + Assert.ok( + prompt.ui.infoBody.textContent.includes( + BASE_URL + "add_search_engine_404.xml" + ), + "Should have included the url in the prompt body" + ); + await PromptTestUtils.handlePrompt(prompt); + Assert.equal(popup.state, "closed"); + }); + }); +}); + +add_task(async function context_same_name() { + info("Checks the context menu with a page that offers same named engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_same_names.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + }); + }); +}); + +add_task(async function context_two() { + info("Checks the context menu with a page that offers two engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_two.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + elt = popup.parentNode.getMenuItem("add-engine-1"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_1")); + }); + }); +}); + +add_task(async function context_many() { + info("Checks the context menu with a page that offers many engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_many.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine menu should be present."); + let separator = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(separator)); + + info("Engines should appear in sub menu"); + let menu = popup.parentNode.getMenuItem("add-engine-menu"); + Assert.ok(BrowserTestUtils.is_visible(menu)); + Assert.ok( + !menu.nextElementSibling + ?.getAttribute("anonid") + .startsWith("add-engine") + ); + Assert.ok(menu.hasAttribute("image"), "Menu should have an icon"); + Assert.ok( + !menu.label.includes("add-engine"), + "Menu should not contain an engine name" + ); + + info("Open the submenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + menu.openMenu(true); + await popupShown; + for (let i = 0; i < 4; ++i) { + let elt = popup.parentNode.getMenuItem(`add-engine-${i}`); + Assert.equal(elt.parentNode, menu.menupopup); + Assert.ok(BrowserTestUtils.is_visible(elt)); + } + + info("Click on the first engine to install it"); + let enginePromise = promiseEngine("engine-added", "add_search_engine_0"); + let elt = popup.parentNode.getMenuItem("add-engine-0"); + + elt.closest("menupopup").activateItem(elt); + await enginePromise; + Assert.equal(popup.state, "closed"); + }); + + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("Check the installed engine has been removed"); + // We're below the limit of engines for the menu now. + Assert.ok(!!popup.parentNode.getMenuItem("add-engine-separator")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + + for (let i = 0; i < 3; ++i) { + let elt = popup.parentNode.getMenuItem(`add-engine-${i}`); + Assert.equal(elt.parentNode, popup); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes(`add_search_engine_${i + 1}`)); + } + }); + + info("Remove the engine."); + let engine = await Services.search.getEngineByName("add_search_engine_0"); + await Services.search.removeEngine(engine); + + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine menu should be present."); + let separator = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(separator)); + + info("Engines should appear in sub menu"); + let menu = popup.parentNode.getMenuItem("add-engine-menu"); + Assert.ok(BrowserTestUtils.is_visible(menu)); + Assert.ok( + !menu.nextElementSibling + ?.getAttribute("anonid") + .startsWith("add-engine") + ); + + info("Open the submenu"); + let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + menu.openMenu(true); + await popupShown; + for (let i = 0; i < 4; ++i) { + let elt = popup.parentNode.getMenuItem(`add-engine-${i}`); + Assert.equal(elt.parentNode, menu.menupopup); + if ( + AppConstants.platform != "macosx" || + !Services.prefs.getBoolPref( + "widget.macos.native-context-menus", + false + ) + ) { + Assert.ok(BrowserTestUtils.is_visible(elt)); + } + } + }); + }); +}); + +add_task(async function context_after_customize() { + info("Checks the context menu after customization."); + let url = getRootDirectory(gTestPath) + "add_search_engine_one.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + }); + + let promise = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await promise; + promise = BrowserTestUtils.waitForEvent(gNavToolbox, "aftercustomization"); + gCustomizeMode.exit(); + await promise; + + await UrlbarTestUtils.withContextMenu(window, async popup => { + info("The separator and the add engine item should be present."); + let elt = popup.parentNode.getMenuItem("add-engine-separator"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + + Assert.ok(!popup.parentNode.getMenuItem("add-engine-menu")); + Assert.ok(!popup.parentNode.getMenuItem("add-engine-1")); + + elt = popup.parentNode.getMenuItem("add-engine-0"); + Assert.ok(BrowserTestUtils.is_visible(elt)); + await document.l10n.translateElements([elt]); + Assert.ok(elt.label.includes("add_search_engine_0")); + }); + }); +}); + +function promiseEngine(expectedData, expectedEngineName) { + info(`Waiting for engine ${expectedData}`); + return TestUtils.topicObserved( + "browser-search-engine-modified", + (engine, data) => { + info(`Got engine ${engine.wrappedJSObject.name} ${data}`); + return ( + expectedData == data && + expectedEngineName == engine.wrappedJSObject.name + ); + } + ).then(([engine, data]) => engine); +} diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_backspaced.js b/browser/components/urlbar/tests/browser/browser_autoFill_backspaced.js new file mode 100644 index 0000000000..65f533c0fe --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_backspaced.js @@ -0,0 +1,268 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test ensures that backspacing autoFilled values still allows to + * confirm the remaining value. + */ + +"use strict"; + +async function test_autocomplete(data) { + let { desc, typed, autofilled, modified, keys, type, onAutoFill } = data; + info(desc); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + fireInputEvent: true, + }); + Assert.equal(gURLBar.value, autofilled, "autofilled value is as expected"); + if (onAutoFill) { + onAutoFill(); + } + + info("Synthesizing keys"); + for (let key of keys) { + let args = Array.isArray(key) ? key : [key]; + EventUtils.synthesizeKey(...args); + } + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal(gURLBar.value, modified, "backspaced value is as expected"); + + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 0, + "Should get at least 1 result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + Assert.equal(result.type, type, "Should have the correct result type"); + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.blur(); +} + +add_task(async function () { + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/foo", + ]); + // Bookmark the page so it ignores autofill threshold and doesn't risk to + // not be autofilled. + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + await test_autocomplete({ + desc: "DELETE the autofilled part should search", + typed: "exam", + autofilled: "example.com/", + modified: "exam", + keys: ["KEY_Delete"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + await test_autocomplete({ + desc: "DELETE the final slash should visit", + typed: "example.com", + autofilled: "example.com/", + modified: "example.com", + keys: ["KEY_Delete"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "BACK_SPACE the autofilled part should search", + typed: "exam", + autofilled: "example.com/", + modified: "exam", + keys: ["KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + await test_autocomplete({ + desc: "BACK_SPACE the final slash should visit", + typed: "example.com", + autofilled: "example.com/", + modified: "example.com", + keys: ["KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "DELETE the autofilled part, then BACK_SPACE, should search", + typed: "exam", + autofilled: "example.com/", + modified: "exa", + keys: ["KEY_Delete", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + await test_autocomplete({ + desc: "DELETE the final slash, then BACK_SPACE, should search", + typed: "example.com", + autofilled: "example.com/", + modified: "example.co", + keys: ["KEY_Delete", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "BACK_SPACE the autofilled part, then BACK_SPACE, should search", + typed: "exam", + autofilled: "example.com/", + modified: "exa", + keys: ["KEY_Backspace", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + await test_autocomplete({ + desc: "BACK_SPACE the final slash, then BACK_SPACE, should search", + typed: "example.com", + autofilled: "example.com/", + modified: "example.co", + keys: ["KEY_Backspace", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "BACK_SPACE after blur should search", + typed: "ex", + autofilled: "example.com/", + modified: "e", + keys: ["KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + onAutoFill: () => { + gURLBar.blur(); + gURLBar.focus(); + Assert.equal( + gURLBar.selectionStart, + gURLBar.value.length, + "blur/focus should not change selection" + ); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "blur/focus should not change selection" + ); + }, + }); + await test_autocomplete({ + desc: "DELETE after blur should search", + typed: "ex", + autofilled: "example.com/", + modified: "e", + keys: ["KEY_ArrowLeft", "KEY_Delete"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + onAutoFill: () => { + gURLBar.blur(); + gURLBar.focus(); + Assert.equal( + gURLBar.selectionStart, + gURLBar.value.length, + "blur/focus should not change selection" + ); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "blur/focus should not change selection" + ); + }, + }); + await test_autocomplete({ + desc: "double BACK_SPACE after blur should search", + typed: "exa", + autofilled: "example.com/", + modified: "e", + keys: ["KEY_Backspace", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + onAutoFill: () => { + gURLBar.blur(); + gURLBar.focus(); + Assert.equal( + gURLBar.selectionStart, + gURLBar.value.length, + "blur/focus should not change selection" + ); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "blur/focus should not change selection" + ); + }, + }); + + await test_autocomplete({ + desc: "Right arrow key and then backspace should delete the backslash and not re-trigger autofill", + typed: "ex", + autofilled: "example.com/", + modified: "example.com", + keys: ["KEY_ArrowRight", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "Right arrow key, selecting the last few characters using the keyboard, and then backspace should delete the characters and not re-trigger autofill", + typed: "ex", + autofilled: "example.com/", + modified: "example.c", + keys: [ + "KEY_ArrowRight", + ["KEY_ArrowLeft", { shiftKey: true }], + ["KEY_ArrowLeft", { shiftKey: true }], + ["KEY_ArrowLeft", { shiftKey: true }], + "KEY_Backspace", + ], + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + + await test_autocomplete({ + desc: "End and then backspace should delete the backslash and not re-trigger autofill", + typed: "ex", + autofilled: "example.com/", + modified: "example.com", + keys: [ + AppConstants.platform == "macosx" + ? ["KEY_ArrowRight", { metaKey: true }] + : "KEY_End", + "KEY_Backspace", + ], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await test_autocomplete({ + desc: "Clicking in the input after the text and then backspace should delete the backslash and not re-trigger autofill", + typed: "ex", + autofilled: "example.com/", + modified: "example.com", + keys: ["KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + onAutoFill: () => { + // This assumes that the center of the input is to the right of the end + // of the text, so the caret is placed at the end of the text on click. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }, + }); + + await test_autocomplete({ + desc: "Selecting the next result and then backspace should delete the last character and not re-trigger autofill", + typed: "ex", + autofilled: "example.com/", + modified: "example.com/fo", + keys: ["KEY_ArrowDown", "KEY_Backspace"], + type: UrlbarUtils.RESULT_TYPE.URL, + }); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_canonize.js b/browser/components/urlbar/tests/browser/browser_autoFill_canonize.js new file mode 100644 index 0000000000..af6a2eb08b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_canonize.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This test ensures that pressing ctrl+enter bypasses the autoFilled + * value, and only considers what the user typed (but not just enter). + */ + +async function test_autocomplete(data) { + let { desc, typed, autofilled, modified, waitForUrl, keys } = data; + info(desc); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + }); + Assert.equal(gURLBar.value, autofilled, "autofilled value is as expected"); + + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + waitForUrl, + gBrowser.selectedBrowser + ); + + keys.forEach(([key, mods]) => EventUtils.synthesizeKey(key, mods)); + + Assert.equal(gURLBar.value, modified, "value is as expected"); + + await promiseLoad; + gURLBar.blur(); +} + +add_task(async function () { + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + + // Add a typed visit, so it will be autofilled. + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }); + + await test_autocomplete({ + desc: "CTRL+ENTER on the autofilled part should use autofill", + typed: "exam", + autofilled: "example.com/", + modified: "example.com", + waitForUrl: "http://example.com/", + keys: [["KEY_Enter"]], + }); + + await test_autocomplete({ + desc: "CTRL+ENTER on the autofilled part should bypass autofill", + typed: "exam", + autofilled: "example.com/", + modified: "https://www.exam.com", + waitForUrl: "https://www.exam.com/", + keys: [["KEY_Enter", { ctrlKey: true }]], + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_caretNotAtEnd.js b/browser/components/urlbar/tests/browser/browser_autoFill_caretNotAtEnd.js new file mode 100644 index 0000000000..570a1c2c8c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_caretNotAtEnd.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function noAutofillWhenCaretNotAtEnd() { + gURLBar.focus(); + + // Add a visit that can be autofilled. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + + // Fill the input with xample. + gURLBar.inputField.value = "xample"; + + // Move the caret to the beginning and type e. + gURLBar.selectionStart = 0; + gURLBar.selectionEnd = 0; + EventUtils.sendString("e"); + + // Check the first result and input. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!result.autofill, "The first result should not be autofill"); + + Assert.equal(gURLBar.value, "example"); + Assert.equal(gURLBar.selectionStart, 1); + Assert.equal(gURLBar.selectionEnd, 1); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_firstResult.js b/browser/components/urlbar/tests/browser/browser_autoFill_firstResult.js new file mode 100644 index 0000000000..b3fb932c4c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_firstResult.js @@ -0,0 +1,200 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that autofilling the first result of a new search works +// correctly: autofill happens when it should and doesn't when it shouldn't. + +"use strict"; + +add_setup(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(["http://example.com/"]); + + // Disable placeholder completion. The point of this test is to make sure the + // first result is autofilled (or not) correctly. Autofilling the placeholder + // before the search starts interferes with that. + gURLBar._enableAutofillPlaceholder = false; + registerCleanupFunction(async () => { + gURLBar._enableAutofillPlaceholder = true; + }); +}); + +// The first result should be autofilled when all conditions are met. This also +// does a sanity check to make sure that placeholder autofill is correctly +// disabled, which is helpful for all tasks here and is why this one is first. +add_task(async function successfulAutofill() { + // Do a simple search that should autofill. This will also set up the + // autofill placeholder string, which next we make sure is *not* autofilled. + await doInitialAutofillSearch(); + + // As a sanity check, do another search to make sure the placeholder is *not* + // autofilled. Make sure it's not autofilled by checking the input value and + // selection *before* the search completes. If placeholder autofill was not + // correctly disabled, then these assertions will fail. + + gURLBar.value = "exa"; + UrlbarTestUtils.fireInputEvent(window); + + // before the search completes: no autofill + Assert.equal(gURLBar.value, "exa"); + Assert.equal(gURLBar.selectionStart, "exa".length); + Assert.equal(gURLBar.selectionEnd, "exa".length); + + await UrlbarTestUtils.promiseSearchComplete(window); + + // after the search completes: successful autofill + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "exa".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); +}); + +// The first result should not be autofilled when it's not an autofill result. +add_task(async function firstResultNotAutofill() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill); + Assert.equal(gURLBar.value, "foo"); + Assert.equal(gURLBar.selectionStart, "foo".length); + Assert.equal(gURLBar.selectionEnd, "foo".length); +}); + +// The first result should *not* be autofilled when the placeholder is not +// selected, the selection is empty, and the caret is *not* at the end of the +// search string. +add_task(async function caretNotAtEndOfSearchString() { + // To set up the placeholder, do an initial search that triggers autofill. + await doInitialAutofillSearch(); + + // Now do another search but set the caret to somewhere else besides the end + // of the new search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + selectionStart: "exa".length, + selectionEnd: "exa".length, + fireInputEvent: false, + }); + + // The first result should be an autofill result, but it should not have been + // autofilled. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "exam"); + Assert.equal(gURLBar.selectionStart, "exa".length); + Assert.equal(gURLBar.selectionEnd, "exa".length); + + await cleanUp(); +}); + +// The first result should *not* be autofilled when the placeholder is not +// selected, the selection is *not* empty, and the caret is at the end of the +// search string. +add_task(async function selectionNotEmpty() { + // To set up the placeholder, do an initial search that triggers autofill. + await doInitialAutofillSearch(); + + // Now do another search. Set the selection end at the end of the search + // string, but make the selection non-empty. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + selectionStart: "exa".length, + selectionEnd: "exam".length, + }); + + // The first result should be an autofill result, but it should not have been + // autofilled. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "exam"); + Assert.equal(gURLBar.selectionStart, "exa".length); + Assert.equal(gURLBar.selectionEnd, "exam".length); + + await cleanUp(); +}); + +// The first result should be autofilled when the placeholder is not selected, +// the selection is empty, and the caret is at the end of the search string. +add_task(async function successfulAutofillAfterSettingPlaceholder() { + // To set up the placeholder, do an initial search that triggers autofill. + await doInitialAutofillSearch(); + + // Now do another search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + selectionStart: "exam".length, + selectionEnd: "exam".length, + }); + + // It should be autofilled. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "exam".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + await cleanUp(); +}); + +// The first result should be autofilled when the placeholder *is* selected -- +// more precisely, when the portion of the placeholder after the new search +// string is selected. +add_task(async function successfulAutofillPlaceholderSelected() { + // To set up the placeholder, do an initial search that triggers autofill. + await doInitialAutofillSearch(); + + // Now do another search and select the portion of the placeholder after the + // new search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + selectionStart: "exam".length, + selectionEnd: "example.com/".length, + }); + + // It should be autofilled. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "exam".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + await cleanUp(); +}); + +async function doInitialAutofillSearch() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); +} + +async function cleanUp() { + // In some cases above, a test task searches for "exam" at the end, and then + // the next task searches for "ex". Autofill results will not be allowed in + // the next task in that case since the old search string starts with the new + // search string. To prevent one task from interfering with the next, do a + // search that changes the search string. Also close the popup while we're + // here, although that's not really necessary. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "reset last search string", + }); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_paste.js b/browser/components/urlbar/tests/browser/browser_autoFill_paste.js new file mode 100644 index 0000000000..47d92cb7d3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_paste.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks we don't autofill on paste. + +"use strict"; + +add_task(async function test() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(["http://example.com/"]); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); + + // Search for "e". It should autofill to example.com/. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "e", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "e".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Now paste. + await selectAndPaste("ex"); + + // Nothing should have been autofilled. + await UrlbarTestUtils.promiseSearchComplete(window); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill); + Assert.equal(gURLBar.value, "ex"); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "ex".length); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_placeholder.js b/browser/components/urlbar/tests/browser/browser_autoFill_placeholder.js new file mode 100644 index 0000000000..6fcd664de0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_placeholder.js @@ -0,0 +1,1017 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that the autofill placeholder value is autofilled +// correctly. The placeholder is a string that we immediately autofill when a +// search starts and before its first result arrives in order to prevent text +// flicker in the input. +// +// Because this test specifically checks autofill *before* searches complete, we +// can't use promiseAutocompleteResultPopup() or other helpers that wait for +// searches to complete. Instead the test uses fireInputEvent() to trigger +// placeholder autofill and then immediately checks autofill status. + +"use strict"; + +// Allow more time for verify mode. +requestLongerTimeout(5); + +add_setup(async function () { + await cleanUp(); +}); + +// Basic origin autofill test. +add_task(async function origin() { + await addVisits("http://example.com/"); + + await search({ + searchString: "ex", + valueBefore: "ex", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + await search({ + searchString: "exa", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + await search({ + searchString: "EXAM", + valueBefore: "EXAMple.com/", + valueAfter: "EXAMple.com/", + placeholderAfter: "EXAMple.com/", + }); + await search({ + searchString: "eXaMp", + valueBefore: "eXaMple.com/", + valueAfter: "eXaMple.com/", + placeholderAfter: "eXaMple.com/", + }); + await search({ + searchString: "exampL", + valueBefore: "exampLe.com/", + valueAfter: "exampLe.com/", + placeholderAfter: "exampLe.com/", + }); + await search({ + searchString: "example.com", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + await search({ + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + await cleanUp(); +}); + +// Basic URL autofill test. +add_task(async function url() { + await addVisits("http://example.com/aaa/bbb/ccc"); + + await search({ + searchString: "example.com/a", + valueBefore: "example.com/a", + valueAfter: "example.com/aaa/", + placeholderAfter: "example.com/aaa/", + }); + await search({ + searchString: "EXAmple.com/aA", + valueBefore: "EXAmple.com/aAa/", + valueAfter: "EXAmple.com/aAa/", + placeholderAfter: "EXAmple.com/aAa/", + }); + await search({ + searchString: "example.com/aAa", + valueBefore: "example.com/aAa/", + valueAfter: "example.com/aAa/", + placeholderAfter: "example.com/aAa/", + }); + await search({ + searchString: "example.com/aaa/", + valueBefore: "example.com/aaa/", + valueAfter: "example.com/aaa/", + placeholderAfter: "example.com/aaa/", + }); + await search({ + searchString: "example.com/aaa/b", + valueBefore: "example.com/aaa/b", + valueAfter: "example.com/aaa/bbb/", + placeholderAfter: "example.com/aaa/bbb/", + }); + await search({ + searchString: "example.com/aAa/bB", + valueBefore: "example.com/aAa/bBb/", + valueAfter: "example.com/aAa/bBb/", + placeholderAfter: "example.com/aAa/bBb/", + }); + await search({ + searchString: "example.com/aAa/bBb", + valueBefore: "example.com/aAa/bBb/", + valueAfter: "example.com/aAa/bBb/", + placeholderAfter: "example.com/aAa/bBb/", + }); + await search({ + searchString: "example.com/aaa/bbb/", + valueBefore: "example.com/aaa/bbb/", + valueAfter: "example.com/aaa/bbb/", + placeholderAfter: "example.com/aaa/bbb/", + }); + await search({ + searchString: "example.com/aaa/bbb/c", + valueBefore: "example.com/aaa/bbb/c", + valueAfter: "example.com/aaa/bbb/ccc", + placeholderAfter: "example.com/aaa/bbb/ccc", + }); + await search({ + searchString: "example.com/aAa/bBb/cC", + valueBefore: "example.com/aAa/bBb/cCc", + valueAfter: "example.com/aAa/bBb/cCc", + placeholderAfter: "example.com/aAa/bBb/cCc", + }); + await search({ + searchString: "example.com/aaa/bbb/ccc", + valueBefore: "example.com/aaa/bbb/ccc", + valueAfter: "example.com/aaa/bbb/ccc", + placeholderAfter: "example.com/aaa/bbb/ccc", + }); + + await cleanUp(); +}); + +// Basic adaptive history autofill test. +add_task(async function adaptiveHistory() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + await addVisits("http://example.com/test"); + await UrlbarUtils.addToInputHistory("http://example.com/test", "exa"); + + await search({ + searchString: "exa", + valueBefore: "exa", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "EXAM", + valueBefore: "EXAMple.com/test", + valueAfter: "EXAMple.com/test", + placeholderAfter: "EXAMple.com/test", + }); + await search({ + searchString: "eXaMpLe", + valueBefore: "eXaMpLe.com/test", + valueAfter: "eXaMpLe.com/test", + placeholderAfter: "eXaMpLe.com/test", + }); + await search({ + searchString: "example.", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "example.c", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "example.com", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "example.com/", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "example.com/T", + valueBefore: "example.com/Test", + valueAfter: "example.com/Test", + placeholderAfter: "example.com/Test", + }); + await search({ + searchString: "eXaMple.com/tE", + valueBefore: "eXaMple.com/tEst", + valueAfter: "eXaMple.com/tEst", + placeholderAfter: "eXaMple.com/tEst", + }); + await search({ + searchString: "example.com/tes", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + await search({ + searchString: "example.com/test", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + await cleanUp(); +}); + +// Search engine token alias test (aliases that start with "@"). +add_task(async function tokenAlias() { + // We have built-in engine aliases that may conflict with the one we choose + // here in terms of autofill, so be careful and choose a weird alias. + await SearchTestUtils.installSearchExtension({ keyword: "@__example" }); + + await search({ + searchString: "@__ex", + valueBefore: "@__ex", + valueAfter: "@__example ", + placeholderAfter: "@__example ", + }); + await search({ + searchString: "@__exa", + valueBefore: "@__example ", + valueAfter: "@__example ", + placeholderAfter: "@__example ", + }); + await search({ + searchString: "@__EXAM", + valueBefore: "@__EXAMple ", + valueAfter: "@__EXAMple ", + placeholderAfter: "@__EXAMple ", + }); + await search({ + searchString: "@__eXaMp", + valueBefore: "@__eXaMple ", + valueAfter: "@__eXaMple ", + placeholderAfter: "@__eXaMple ", + }); + await search({ + searchString: "@__exampl", + valueBefore: "@__example ", + valueAfter: "@__example ", + placeholderAfter: "@__example ", + }); + + await cleanUp(); +}); + +// The placeholder should not be used for a search that does not autofill, and +// it should be cleared after the search completes. +add_task(async function noAutofill() { + await addVisits("http://example.com/"); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + await search({ + searchString: "ex", + valueBefore: "ex", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + // Search with a string that does not match the placeholder. Placeholder + // autofill shouldn't happen. + await search({ + searchString: "moz", + valueBefore: "moz", + valueAfter: "moz", + placeholderAfter: "", + }); + + // Search for "ex" again. It should be autofilled when the search completes + // but the placeholder will not be autofilled. + await search({ + searchString: "ex", + valueBefore: "ex", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + // Continue with a series of searches that should all use the placeholder. + await search({ + searchString: "exa", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + await search({ + searchString: "EXAM", + valueBefore: "EXAMple.com/", + valueAfter: "EXAMple.com/", + placeholderAfter: "EXAMple.com/", + }); + await search({ + searchString: "eXaMp", + valueBefore: "eXaMple.com/", + valueAfter: "eXaMple.com/", + placeholderAfter: "eXaMple.com/", + }); + await search({ + searchString: "exampl", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + await cleanUp(); +}); + +// The placeholder should not be used for a search that autofills a different +// value. +add_task(async function differentAutofill() { + await addVisits("http://mozilla.org/", "http://example.com/"); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + await search({ + searchString: "moz", + valueBefore: "moz", + valueAfter: "mozilla.org/", + placeholderAfter: "mozilla.org/", + }); + + // Search with a string that does not match the placeholder but does trigger + // autofill. Placeholder autofill shouldn't happen. + await search({ + searchString: "ex", + valueBefore: "ex", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + // Continue with a series of searches that should all use the placeholder. + await search({ + searchString: "exa", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + await search({ + searchString: "EXAm", + valueBefore: "EXAmple.com/", + valueAfter: "EXAmple.com/", + placeholderAfter: "EXAmple.com/", + }); + + // Search for "moz" again. It should be autofilled. Placeholder autofill + // shouldn't happen. + await search({ + searchString: "moz", + valueBefore: "moz", + valueAfter: "mozilla.org/", + placeholderAfter: "mozilla.org/", + }); + + // Continue with a series of searches that should all use the placeholder. + await search({ + searchString: "mozi", + valueBefore: "mozilla.org/", + valueAfter: "mozilla.org/", + placeholderAfter: "mozilla.org/", + }); + await search({ + searchString: "MOZil", + valueBefore: "MOZilla.org/", + valueAfter: "MOZilla.org/", + placeholderAfter: "MOZilla.org/", + }); + + await cleanUp(); +}); + +// The placeholder should not be used for a search that uses a bookmark keyword +// even when the keyword matches the placeholder, and the placeholder should be +// cleared after the search completes. +add_task(async function bookmarkKeyword() { + // Add a visit to example.com. + await addVisits("https://example.com/"); + + // Add a bookmark keyword that is a prefix of example.com. + await PlacesUtils.keywords.insert({ + keyword: "ex", + url: "https://somekeyword.com/", + }); + + // Do an initial search that triggers autofill for the visit so that the + // placeholder has an initial value of "example.com/". + await search({ + searchString: "e", + valueBefore: "e", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + // Do a search that matches the bookmark keyword. The placeholder from the + // search above should be autofilled since the autofill placeholder + // ("example.com/") starts with the keyword ("ex"), but then when the bookmark + // result arrives, the autofilled value and placeholder should be cleared. + await search({ + searchString: "ex", + valueBefore: "example.com/", + valueAfter: "ex", + placeholderAfter: "", + }); + + // Do another search that simulates the user continuing to type "example". No + // placeholder should be autofilled, but once the autofill result arrives for + // the visit, "example.com/" should be autofilled. + await search({ + searchString: "exa", + valueBefore: "exa", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + await PlacesUtils.keywords.remove("ex"); + await cleanUp(); +}); + +// The placeholder should not be used for a search that doesn't match its URI +// fragment. This task uses a URL whose path is "/". +add_task(async function noURIFragmentMatch1() { + await addVisits("https://example.com/#TEST"); + + const testData = [ + { + desc: "Autofill example.com/#TEST then search for example.com/#Te", + searches: [ + { + searchString: "example.com/#T", + valueBefore: "example.com/#T", + valueAfter: "example.com/#TEST", + placeholderAfter: "example.com/#TEST", + }, + { + searchString: "example.com/#Te", + valueBefore: "example.com/#Te", + valueAfter: "example.com/#Te", + placeholderAfter: "", + }, + ], + }, + { + desc: "Autofill https://example.com/#TEST then search for https://example.com/#Te", + searches: [ + { + searchString: "https://example.com/#T", + valueBefore: "https://example.com/#T", + valueAfter: "https://example.com/#TEST", + placeholderAfter: "https://example.com/#TEST", + }, + { + searchString: "https://example.com/#Te", + valueBefore: "https://example.com/#Te", + valueAfter: "https://example.com/#Te", + placeholderAfter: "", + }, + ], + }, + { + desc: "Autofill example.com/#TEST then search for example.com/", + searches: [ + { + searchString: "example.com/#T", + valueBefore: "example.com/#T", + valueAfter: "example.com/#TEST", + placeholderAfter: "example.com/#TEST", + }, + { + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + ]; + + for (const { desc, searches } of testData) { + info("Running subtest: " + desc); + + for (let i = 0; i < searches.length; i++) { + info("Doing search at index " + i); + await search(searches[i]); + } + + // Clear the placeholder for the next subtest. + info("Doing extra search to clear placeholder"); + await search({ + searchString: "no match", + valueBefore: "no match", + valueAfter: "no match", + placeholderAfter: "", + }); + } + + await cleanUp(); +}); + +// The placeholder should not be used for a search that doesn't match its URI +// fragment. This task uses a URL whose path is "/foo". +add_task(async function noURIFragmentMatch2() { + await addVisits("https://example.com/foo#TEST"); + + const testData = [ + { + desc: "Autofill example.com/foo#TEST then search for example.com/foo#Te", + searches: [ + { + searchString: "example.com/foo#T", + valueBefore: "example.com/foo#T", + valueAfter: "example.com/foo#TEST", + placeholderAfter: "example.com/foo#TEST", + }, + { + searchString: "example.com/foo#Te", + valueBefore: "example.com/foo#Te", + valueAfter: "example.com/foo#Te", + placeholderAfter: "", + }, + ], + }, + { + desc: "Autofill https://example.com/foo#TEST then search for https://example.com/foo#Te", + searches: [ + { + searchString: "https://example.com/foo#T", + valueBefore: "https://example.com/foo#T", + valueAfter: "https://example.com/foo#TEST", + placeholderAfter: "https://example.com/foo#TEST", + }, + { + searchString: "https://example.com/foo#Te", + valueBefore: "https://example.com/foo#Te", + valueAfter: "https://example.com/foo#Te", + placeholderAfter: "", + }, + ], + }, + { + desc: "Autofill example.com/foo#TEST then search for example.com/", + searches: [ + { + searchString: "example.com/foo#T", + valueBefore: "example.com/foo#T", + valueAfter: "example.com/foo#TEST", + placeholderAfter: "example.com/foo#TEST", + }, + { + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + ]; + + for (const { desc, searches } of testData) { + info("Running subtest: " + desc); + + for (let i = 0; i < searches.length; i++) { + info("Doing search at index " + i); + await search(searches[i]); + } + + // Clear the placeholder for the next subtest. + info("Doing extra search to clear placeholder"); + await search({ + searchString: "no match", + valueBefore: "no match", + valueAfter: "no match", + placeholderAfter: "", + }); + } + + await cleanUp(); +}); + +// The placeholder should not be used for a search that does not autofill its +// URL path. +add_task(async function noPathMatch() { + await addVisits("http://example.com/shallow/deep/file"); + + const testData = [ + { + desc: "Autofill example.com/shallow/ then search for exam", + searches: [ + { + searchString: "example.com/s", + valueBefore: "example.com/s", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + { + searchString: "exam", + valueBefore: "exam", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/ then search for example.com/", + searches: [ + { + searchString: "example.com/s", + valueBefore: "example.com/s", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + { + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/ then search for exam", + searches: [ + { + searchString: "example.com/shallow/d", + valueBefore: "example.com/shallow/d", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + { + searchString: "exam", + valueBefore: "exam", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/ then search for example.com/", + searches: [ + { + searchString: "example.com/shallow/d", + valueBefore: "example.com/shallow/d", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + { + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/ then search for example.com/s", + searches: [ + { + searchString: "example.com/shallow/d", + valueBefore: "example.com/shallow/d", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + { + searchString: "example.com/s", + valueBefore: "example.com/s", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/ then search for example.com/shallow/", + searches: [ + { + searchString: "example.com/shallow/d", + valueBefore: "example.com/shallow/d", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + { + searchString: "example.com/shallow/", + valueBefore: "example.com/shallow/", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for exam", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "exam", + valueBefore: "exam", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for example.com/", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/", + valueBefore: "example.com/", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for example.com/s", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/s", + valueBefore: "example.com/s", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for example.com/shallow/", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/shallow/", + valueBefore: "example.com/shallow/", + valueAfter: "example.com/shallow/", + placeholderAfter: "example.com/shallow/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for example.com/shallow/d", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/shallow/d", + valueBefore: "example.com/shallow/d", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + ], + }, + { + desc: "Autofill example.com/shallow/deep/file then search for example.com/shallow/deep/", + searches: [ + { + searchString: "example.com/shallow/deep/f", + valueBefore: "example.com/shallow/deep/f", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/shallow/deep/fi", + valueBefore: "example.com/shallow/deep/file", + valueAfter: "example.com/shallow/deep/file", + placeholderAfter: "example.com/shallow/deep/file", + }, + { + searchString: "example.com/shallow/deep/", + valueBefore: "example.com/shallow/deep/", + valueAfter: "example.com/shallow/deep/", + placeholderAfter: "example.com/shallow/deep/", + }, + ], + }, + ]; + + for (const { desc, searches } of testData) { + info("Running subtest: " + desc); + + for (let i = 0; i < searches.length; i++) { + info("Doing search at index " + i); + await search(searches[i]); + } + + // Clear the placeholder for the next subtest. + info("Doing extra search to clear placeholder"); + await search({ + searchString: "no match", + valueBefore: "no match", + valueAfter: "no match", + placeholderAfter: "", + }); + } + + await cleanUp(); +}); + +// An adaptive history placeholder should not be used for a search that does not +// autofill it. +add_task(async function noAdaptiveHistoryMatch() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + await addVisits("http://example.com/test"); + await UrlbarUtils.addToInputHistory("http://example.com/test", "exam"); + + // Search for a longer string than the adaptive history input. Adaptive + // history autofill should be triggered. + await search({ + searchString: "example", + valueBefore: "example", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + + // Search for the same string as the adaptive history input. The placeholder + // from the previous search should be used and adaptive history autofill + // should be triggered. + await search({ + searchString: "exam", + valueBefore: "example.com/test", + valueAfter: "example.com/test", + placeholderAfter: "example.com/test", + }); + + // Search for a shorter string than the adaptive history input. The + // placeholder from the previous search should not be used since the search + // string is shorter than the adaptive history input. + await search({ + searchString: "ex", + valueBefore: "ex", + valueAfter: "example.com/", + placeholderAfter: "example.com/", + }); + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + await cleanUp(); +}); + +/** + * This function does the following: + * + * 1. Starts a search with `searchString` but doesn't wait for it to complete. + * 2. Compares the input value to `valueBefore`. If anything is autofilled at + * this point, it will be due to the placeholder. + * 3. Waits for the search to complete. + * 4. Compares the input value to `valueAfter`. If anything is autofilled at + * this point, it will be due to the autofill result fetched by the search. + * 5. Compares the placeholder to `placeholderAfter`. + * + * @param {object} options + * The options object. + * @param {string} options.searchString + * The search string. + * @param {string} options.valueBefore + * The expected input value before the search completes. + * @param {string} options.valueAfter + * The expected input value after the search completes. + * @param {string} options.placeholderAfter + * The expected placeholder value after the search completes. + * @returns {Promise} + */ +async function search({ + searchString, + valueBefore, + valueAfter, + placeholderAfter, +}) { + info( + "Searching: " + + JSON.stringify({ + searchString, + valueBefore, + valueAfter, + placeholderAfter, + }) + ); + + await SimpleTest.promiseFocus(window); + gURLBar.inputField.focus(); + + // Set the input value and move the caret to the end to simulate the user + // typing. It's important the caret is at the end because otherwise autofill + // won't happen. + gURLBar.value = searchString; + gURLBar.inputField.setSelectionRange( + searchString.length, + searchString.length + ); + + // Placeholder autofill is done on input, so fire an input event. We can't use + // `promiseAutocompleteResultPopup()` or other helpers that wait for the + // search to complete because we are specifically checking placeholder + // autofill before the search completes. + UrlbarTestUtils.fireInputEvent(window); + + // Check the input value and selection immediately, before waiting on the + // search to complete. + Assert.equal( + gURLBar.value, + valueBefore, + "gURLBar.value before the search completes" + ); + Assert.equal( + gURLBar.selectionStart, + searchString.length, + "gURLBar.selectionStart before the search completes" + ); + Assert.equal( + gURLBar.selectionEnd, + valueBefore.length, + "gURLBar.selectionEnd before the search completes" + ); + + // Wait for the search to complete. + info("Waiting for the search to complete"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Check the final value after the results arrived. + Assert.equal( + gURLBar.value, + valueAfter, + "gURLBar.value after the search completes" + ); + Assert.equal( + gURLBar.selectionStart, + searchString.length, + "gURLBar.selectionStart after the search completes" + ); + Assert.equal( + gURLBar.selectionEnd, + valueAfter.length, + "gURLBar.selectionEnd after the search completes" + ); + + // Check the placeholder. + if (placeholderAfter) { + Assert.ok( + gURLBar._autofillPlaceholder, + "gURLBar._autofillPlaceholder exists after the search completes" + ); + Assert.strictEqual( + gURLBar._autofillPlaceholder.value, + placeholderAfter, + "gURLBar._autofillPlaceholder.value after the search completes" + ); + } else { + Assert.strictEqual( + gURLBar._autofillPlaceholder, + null, + "gURLBar._autofillPlaceholder does not exist after the search completes" + ); + } + + // Check the first result. + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + !!details.autofill, + !!placeholderAfter, + "First result is an autofill result iff a placeholder is expected" + ); +} + +/** + * Adds enough visits to URLs so their origins start autofilling. + * + * @param {...string} urls The URLs to add visits to. + */ +async function addVisits(...urls) { + for (let url of urls) { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + } +} + +async function cleanUp() { + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_preserve.js b/browser/components/urlbar/tests/browser/browser_autoFill_preserve.js new file mode 100644 index 0000000000..a197be8bf1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_preserve.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that a few of aspects of autofill are correctly +// preserved: +// +// * Autofill should preserve the user's case. If you type ExA, it should be +// autofilled to ExAmple.com/, not example.com/. +// * When you key down and then back up to the autofill result, autofill should +// be restored, with the text selection and the user's case both preserved. +// * When you key down/up so that no result is selected, the value that the +// user typed to trigger autofill should be restored in the input. + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + // The example.com engine can interfere with this test. + set: [["browser.urlbar.suggest.engines", false]], + }); + await cleanUp(); +}); + +add_task(async function origin() { + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://mozilla.org/example", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ExA", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "ExAmple.com/"); + Assert.equal(gURLBar.selectionStart, "ExA".length); + Assert.equal(gURLBar.selectionEnd, "ExAmple.com/".length); + checkKeys([ + ["KEY_ArrowDown", "http://mozilla.org/example", 1], + ["KEY_ArrowDown", "ExA", -1], + ["KEY_ArrowUp", "http://mozilla.org/example", 1], + ["KEY_ArrowUp", "ExAmple.com/", 0], + ["KEY_ArrowUp", "ExA", -1], + ["KEY_ArrowDown", "ExAmple.com/", 0], + ]); + await cleanUp(); +}); + +add_task(async function originPort() { + await PlacesTestUtils.addVisits([ + "http://example.com:8888/", + "http://mozilla.org/example", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ExA", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "ExAmple.com:8888/"); + Assert.equal(gURLBar.selectionStart, "ExA".length); + Assert.equal(gURLBar.selectionEnd, "ExAmple.com:8888/".length); + checkKeys([ + ["KEY_ArrowDown", "http://mozilla.org/example", 1], + ["KEY_ArrowDown", "ExA", -1], + ["KEY_ArrowUp", "http://mozilla.org/example", 1], + ["KEY_ArrowUp", "ExAmple.com:8888/", 0], + ["KEY_ArrowUp", "ExA", -1], + ["KEY_ArrowDown", "ExAmple.com:8888/", 0], + ]); + await cleanUp(); +}); + +add_task(async function originScheme() { + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://mozilla.org/example", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "http://ExA", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "http://ExAmple.com/"); + Assert.equal(gURLBar.selectionStart, "http://ExA".length); + Assert.equal(gURLBar.selectionEnd, "http://ExAmple.com/".length); + checkKeys([ + ["KEY_ArrowDown", "http://mozilla.org/example", 1], + ["KEY_ArrowDown", "http://ExA", -1], + ["KEY_ArrowUp", "http://mozilla.org/example", 1], + ["KEY_ArrowUp", "http://ExAmple.com/", 0], + ["KEY_ArrowUp", "http://ExA", -1], + ["KEY_ArrowDown", "http://ExAmple.com/", 0], + ]); + await cleanUp(); +}); + +add_task(async function originPortScheme() { + await PlacesTestUtils.addVisits([ + "http://example.com:8888/", + "http://mozilla.org/example", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "http://ExA", + fireInputEvents: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "http://ExAmple.com:8888/"); + Assert.equal(gURLBar.selectionStart, "http://ExA".length); + Assert.equal(gURLBar.selectionEnd, "http://ExAmple.com:8888/".length); + checkKeys([ + ["KEY_ArrowDown", "http://mozilla.org/example", 1], + ["KEY_ArrowDown", "http://ExA", -1], + ["KEY_ArrowUp", "http://mozilla.org/example", 1], + ["KEY_ArrowUp", "http://ExAmple.com:8888/", 0], + ["KEY_ArrowUp", "http://ExA", -1], + ["KEY_ArrowDown", "http://ExAmple.com:8888/", 0], + ]); + await cleanUp(); +}); + +add_task(async function url() { + await PlacesTestUtils.addVisits([ + "http://example.com/foo", + "http://example.com/foo", + "http://example.com/fff", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ExAmple.com/f", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "ExAmple.com/foo"); + Assert.equal(gURLBar.selectionStart, "ExAmple.com/f".length); + Assert.equal(gURLBar.selectionEnd, "ExAmple.com/foo".length); + checkKeys([ + ["KEY_ArrowDown", "http://example.com/fff", 1], + ["KEY_ArrowDown", "ExAmple.com/f", -1], + ["KEY_ArrowUp", "http://example.com/fff", 1], + ["KEY_ArrowUp", "ExAmple.com/foo", 0], + ["KEY_ArrowUp", "ExAmple.com/f", -1], + ["KEY_ArrowDown", "ExAmple.com/foo", 0], + ]); + await cleanUp(); +}); + +add_task(async function urlPort() { + await PlacesTestUtils.addVisits([ + "http://example.com:8888/foo", + "http://example.com:8888/foo", + "http://example.com:8888/fff", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ExAmple.com:8888/f", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "ExAmple.com:8888/foo"); + Assert.equal(gURLBar.selectionStart, "ExAmple.com:8888/f".length); + Assert.equal(gURLBar.selectionEnd, "ExAmple.com:8888/foo".length); + checkKeys([ + ["KEY_ArrowDown", "http://example.com:8888/fff", 1], + ["KEY_ArrowDown", "ExAmple.com:8888/f", -1], + ["KEY_ArrowUp", "http://example.com:8888/fff", 1], + ["KEY_ArrowUp", "ExAmple.com:8888/foo", 0], + ["KEY_ArrowUp", "ExAmple.com:8888/f", -1], + ["KEY_ArrowDown", "ExAmple.com:8888/foo", 0], + ]); + await cleanUp(); +}); + +add_task(async function tokenAlias() { + await SearchTestUtils.installSearchExtension({ keyword: "@example" }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@ExA", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "@ExAmple "); + Assert.equal(gURLBar.selectionStart, "@ExA".length); + Assert.equal(gURLBar.selectionEnd, "@ExAmple ".length); + // Token aliases (1) hide the one-off buttons and (2) show only a single + // result, the "Search with" result for the alias's engine, so there's no way + // to key up/down to change the selection, so this task doesn't check key + // presses like the others do. + await cleanUp(); +}); + +// This test is a little different from the others. It backspaces over the +// autofilled substring and checks that autofill is *not* preserved. +add_task(async function backspaceNoAutofill() { + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/", + "http://mozilla.org/example", + ]); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ExA", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "ExAmple.com/"); + Assert.equal(gURLBar.selectionStart, "ExA".length); + Assert.equal(gURLBar.selectionEnd, "ExAmple.com/".length); + + EventUtils.synthesizeKey("KEY_Backspace"); + await UrlbarTestUtils.promiseSearchComplete(window); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill); + Assert.equal(gURLBar.value, "ExA"); + Assert.equal(gURLBar.selectionStart, "ExA".length); + Assert.equal(gURLBar.selectionEnd, "ExA".length); + + let heuristicValue = "ExA"; + + checkKeys([ + ["KEY_ArrowDown", "http://example.com/", 1], + ["KEY_ArrowDown", "http://mozilla.org/example", 2], + ["KEY_ArrowDown", "ExA", -1], + ["KEY_ArrowUp", "http://mozilla.org/example", 2], + ["KEY_ArrowUp", "http://example.com/", 1], + ["KEY_ArrowUp", heuristicValue, 0], + ["KEY_ArrowUp", "ExA", -1], + ["KEY_ArrowDown", heuristicValue, 0], + ]); + + await cleanUp(); +}); + +function checkKeys(testTuples) { + for (let [key, value, selectedIndex] of testTuples) { + EventUtils.synthesizeKey(key); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), selectedIndex); + Assert.equal(gURLBar.untrimmedValue, value); + } +} + +async function cleanUp() { + EventUtils.synthesizeKey("KEY_Escape"); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_trimURLs.js b/browser/components/urlbar/tests/browser/browser_autoFill_trimURLs.js new file mode 100644 index 0000000000..3e068d52a4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_trimURLs.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that autoFilled values are not trimmed, unless the user +// selects from the autocomplete popup. + +"use strict"; + +add_setup(async function () { + const PREF_TRIMURL = "browser.urlbar.trimURLs"; + const PREF_AUTOFILL = "browser.urlbar.autoFill"; + + registerCleanupFunction(async function () { + Services.prefs.clearUserPref(PREF_TRIMURL); + Services.prefs.clearUserPref(PREF_AUTOFILL); + await PlacesUtils.history.clear(); + gURLBar.handleRevert(); + }); + Services.prefs.setBoolPref(PREF_TRIMURL, true); + Services.prefs.setBoolPref(PREF_AUTOFILL, true); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + // Adding a tab would hit switch-to-tab, so it's safer to just add a visit. + await PlacesTestUtils.addVisits([ + { + uri: "http://www.autofilltrimurl.com/whatever", + }, + { + uri: "https://www.secureautofillurl.com/whatever", + }, + ]); +}); + +async function promiseSearch(searchtext) { + gURLBar.focus(); + gURLBar.inputField.value = searchtext.substr(0, searchtext.length - 1); + EventUtils.sendString(searchtext.substr(-1, 1)); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function promiseTestResult(test) { + info(`Searching for '${test.search}'`); + + await promiseSearch(test.search); + + Assert.equal( + gURLBar.inputField.value, + test.autofilledValue, + `Autofilled value is as expected for search '${test.search}'` + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + Assert.equal( + result.displayed.title, + test.resultListDisplayTitle, + `Autocomplete result should have displayed title as expected for search '${test.search}'` + ); + + Assert.equal( + result.displayed.action, + test.resultListActionText, + `Autocomplete action text should be as expected for search '${test.search}'` + ); + + Assert.equal( + result.type, + test.resultListType, + `Autocomplete result should have searchengine for the type for search '${test.search}'` + ); + + Assert.equal( + !!result.searchParams, + !!test.searchParams, + "Should have search params if expected" + ); + if (test.searchParams) { + let definedParams = {}; + for (let [k, v] of Object.entries(result.searchParams)) { + if (v !== undefined) { + definedParams[k] = v; + } + } + Assert.deepEqual( + definedParams, + test.searchParams, + "Shoud have the correct search params" + ); + } else { + Assert.equal( + result.url, + test.finalCompleteValue, + "Should have the correct URL/finalCompleteValue" + ); + } +} + +const tests = [ + { + search: "http://", + autofilledValue: "http://", + resultListDisplayTitle: "http://", + resultListActionText: "Search with Google", + resultListType: UrlbarUtils.RESULT_TYPE.SEARCH, + searchParams: { + engine: "Google", + query: "http://", + }, + }, + { + search: "https://", + autofilledValue: "https://", + resultListDisplayTitle: "https://", + resultListActionText: "Search with Google", + resultListType: UrlbarUtils.RESULT_TYPE.SEARCH, + searchParams: { + engine: "Google", + query: "https://", + }, + }, + { + search: "au", + autofilledValue: "autofilltrimurl.com/", + resultListDisplayTitle: "www.autofilltrimurl.com", + resultListActionText: "Visit", + resultListType: UrlbarUtils.RESULT_TYPE.URL, + finalCompleteValue: "http://www.autofilltrimurl.com/", + }, + { + search: "http://au", + autofilledValue: "http://autofilltrimurl.com/", + resultListDisplayTitle: "www.autofilltrimurl.com", + resultListActionText: "Visit", + resultListType: UrlbarUtils.RESULT_TYPE.URL, + finalCompleteValue: "http://www.autofilltrimurl.com/", + }, + { + search: "sec", + autofilledValue: "secureautofillurl.com/", + resultListDisplayTitle: "https://www.secureautofillurl.com", + resultListActionText: "Visit", + resultListType: UrlbarUtils.RESULT_TYPE.URL, + finalCompleteValue: "https://www.secureautofillurl.com/", + }, + { + search: "https://sec", + autofilledValue: "https://secureautofillurl.com/", + resultListDisplayTitle: "https://www.secureautofillurl.com", + resultListActionText: "Visit", + resultListType: UrlbarUtils.RESULT_TYPE.URL, + finalCompleteValue: "https://www.secureautofillurl.com/", + }, +]; + +add_task(async function autofill_tests() { + for (let test of tests) { + await promiseTestResult(test); + } +}); + +add_task(async function autofill_complete_domain() { + await promiseSearch("http://www.autofilltrimurl.com"); + Assert.equal( + gURLBar.inputField.value, + "http://www.autofilltrimurl.com/", + "Should have the correct autofill value" + ); + + // Now ensure selecting from the popup correctly trims. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Should have the correct matches" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + gURLBar.inputField.value, + "www.autofilltrimurl.com/whatever", + "Should have applied trim correctly" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_typed.js b/browser/components/urlbar/tests/browser/browser_autoFill_typed.js new file mode 100644 index 0000000000..371e73c400 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_typed.js @@ -0,0 +1,172 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that autofill works as expected when typing, character +// by character. + +"use strict"; + +add_setup(async function () { + await cleanUp(); +}); + +add_task(async function origin() { + await PlacesTestUtils.addVisits(["http://example.com/"]); + // all lowercase + await typeAndCheck([ + ["e", "example.com/"], + ["x", "example.com/"], + ["a", "example.com/"], + ["m", "example.com/"], + ["p", "example.com/"], + ["l", "example.com/"], + ["e", "example.com/"], + [".", "example.com/"], + ["c", "example.com/"], + ["o", "example.com/"], + ["m", "example.com/"], + ["/", "example.com/"], + ]); + gURLBar.value = ""; + // mixed case + await typeAndCheck([ + ["E", "Example.com/"], + ["x", "Example.com/"], + ["A", "ExAmple.com/"], + ["m", "ExAmple.com/"], + ["P", "ExAmPle.com/"], + ["L", "ExAmPLe.com/"], + ["e", "ExAmPLe.com/"], + [".", "ExAmPLe.com/"], + ["C", "ExAmPLe.Com/"], + ["o", "ExAmPLe.Com/"], + ["M", "ExAmPLe.CoM/"], + ["/", "ExAmPLe.CoM/"], + ]); + await cleanUp(); +}); + +add_task(async function url() { + await PlacesTestUtils.addVisits(["http://example.com/foo/bar"]); + // all lowercase + await typeAndCheck([ + ["e", "example.com/"], + ["x", "example.com/"], + ["a", "example.com/"], + ["m", "example.com/"], + ["p", "example.com/"], + ["l", "example.com/"], + ["e", "example.com/"], + [".", "example.com/"], + ["c", "example.com/"], + ["o", "example.com/"], + ["m", "example.com/"], + ["/", "example.com/"], + ["f", "example.com/foo/"], + ["o", "example.com/foo/"], + ["o", "example.com/foo/"], + ["/", "example.com/foo/"], + ["b", "example.com/foo/bar"], + ["a", "example.com/foo/bar"], + ["r", "example.com/foo/bar"], + ]); + gURLBar.value = ""; + // mixed case + await typeAndCheck([ + ["E", "Example.com/"], + ["x", "Example.com/"], + ["A", "ExAmple.com/"], + ["m", "ExAmple.com/"], + ["P", "ExAmPle.com/"], + ["L", "ExAmPLe.com/"], + ["e", "ExAmPLe.com/"], + [".", "ExAmPLe.com/"], + ["C", "ExAmPLe.Com/"], + ["o", "ExAmPLe.Com/"], + ["M", "ExAmPLe.CoM/"], + ["/", "ExAmPLe.CoM/"], + ["f", "ExAmPLe.CoM/foo/"], + ["o", "ExAmPLe.CoM/foo/"], + ["o", "ExAmPLe.CoM/foo/"], + ["/", "ExAmPLe.CoM/foo/"], + ["b", "ExAmPLe.CoM/foo/bar"], + ["a", "ExAmPLe.CoM/foo/bar"], + ["r", "ExAmPLe.CoM/foo/bar"], + ]); + await cleanUp(); +}); + +add_task(async function tokenAlias() { + // We have built-in engine aliases that may conflict with the one we choose + // here in terms of autofill, so be careful and choose a weird alias. + await SearchTestUtils.installSearchExtension({ keyword: "@__example" }); + // all lowercase + await typeAndCheck([ + ["@", "@"], + ["_", "@__example "], + ["_", "@__example "], + ["e", "@__example "], + ["x", "@__example "], + ["a", "@__example "], + ["m", "@__example "], + ["p", "@__example "], + ["l", "@__example "], + ["e", "@__example "], + ]); + gURLBar.value = ""; + // mixed case + await typeAndCheck([ + ["@", "@"], + ["_", "@__example "], + ["_", "@__example "], + ["E", "@__Example "], + ["x", "@__Example "], + ["A", "@__ExAmple "], + ["m", "@__ExAmple "], + ["P", "@__ExAmPle "], + ["L", "@__ExAmPLe "], + ["e", "@__ExAmPLe "], + ]); + await cleanUp(); +}); + +async function typeAndCheck(values) { + gURLBar.focus(); + for (let i = 0; i < values.length; i++) { + let [char, expectedInputValue] = values[i]; + info( + `Typing: i=${i} char=${char} ` + + `substring="${expectedInputValue.substring(0, i + 1)}"` + ); + EventUtils.synthesizeKey(char); + if (i == 0 && char == "@") { + // A single "@" doesn't trigger autofill, so skip the checks below. (It + // shows all the @ aliases.) + continue; + } + await UrlbarTestUtils.promiseSearchComplete(window); + let restIsSpaces = !expectedInputValue.substring(i + 1).trim(); + Assert.equal(gURLBar.value, expectedInputValue); + Assert.equal(gURLBar.selectionStart, i + 1); + Assert.equal(gURLBar.selectionEnd, expectedInputValue.length); + if (restIsSpaces) { + // Autofilled @ aliases have a trailing space. We should check that the + // space is autofilled when each preceding character is typed, but once + // the final non-space char is typed, autofill actually stops and the + // trailing space is not autofilled. (Which is maybe not the way it + // should work...) Skip the check below. + continue; + } + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + } +} + +async function cleanUp() { + gURLBar.value = ""; + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} diff --git a/browser/components/urlbar/tests/browser/browser_autoFill_undo.js b/browser/components/urlbar/tests/browser/browser_autoFill_undo.js new file mode 100644 index 0000000000..c233da80f2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_undo.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks the behavior of text undo (Ctrl-Z, cmd_undo) in regard to +// autofill. + +"use strict"; + +add_task(async function test() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(["http://example.com/"]); + + // Search for "ex". It should autofill to example.com/. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Type an x. + EventUtils.synthesizeKey("x"); + + // Nothing should have been autofilled. + await UrlbarTestUtils.promiseSearchComplete(window); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill); + Assert.equal(gURLBar.value, "exx"); + Assert.equal(gURLBar.selectionStart, "exx".length); + Assert.equal(gURLBar.selectionEnd, "exx".length); + + // Undo the typed x. + goDoCommand("cmd_undo"); + + // The text should be restored to ex[ample.com/] (with the part in brackets + // autofilled and selected). + await UrlbarTestUtils.promiseSearchComplete(window); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(gURLBar.value, "example.com/"); + Assert.ok(!details.autofill, "Autofill should not be set."); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autoOpen.js b/browser/components/urlbar/tests/browser/browser_autoOpen.js new file mode 100644 index 0000000000..8ed7e8e402 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoOpen.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function checkOpensOnFocus(win = window) { + // The view should not open when the input is focused programmatically. + win.gURLBar.blur(); + win.gURLBar.focus(); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + // Check the keyboard shortcut. + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + + // Focus with the mouse. + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); +} + +add_setup(async function () { + // Add some history for the empty panel. + await PlacesTestUtils.addVisits([ + { + uri: "http://mochi.test:8888/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + registerCleanupFunction(() => PlacesUtils.history.clear()); +}); + +add_task(async function test() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async browser => { + await checkOpensOnFocus(); + } + ); +}); + +add_task(async function newtabAndHome() { + for (let url of ["about:newtab", "about:home"]) { + // withNewTab randomly hangs on these pages when waitForLoad = true (the + // default), so pass false. + await BrowserTestUtils.withNewTab( + { gBrowser, url, waitForLoad: false }, + async browser => { + // We don't wait for load, but we must ensure to be on the expected url. + await TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == url, + "Ensure we're on the expected page" + ); + await checkOpensOnFocus(); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "http://example.com/" }, + async otherBrowser => { + await checkOpensOnFocus(); + // Switch back to about:newtab/home. + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(browser) + ); + await checkOpensOnFocus(); + // Switch back to example.com. + await BrowserTestUtils.switchTab( + gBrowser, + gBrowser.getTabForBrowser(otherBrowser) + ); + await checkOpensOnFocus(); + } + ); + // After example.com closes, about:newtab/home is selected again. + await checkOpensOnFocus(); + // Load example.com in the same tab. + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com/" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await checkOpensOnFocus(); + } + ); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_a11y_label.js b/browser/components/urlbar/tests/browser/browser_autocomplete_a11y_label.js new file mode 100644 index 0000000000..ead026244e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_a11y_label.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that we produce good labels for a11y purposes. + */ + +const { CommonUtils } = ChromeUtils.importESModule( + "chrome://mochitests/content/browser/accessible/tests/browser/Common.sys.mjs" +); + +const SUGGEST_ALL_PREF = "browser.search.suggest.enabled"; +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +let accService; + +async function getResultText(element, expectedValue, description = "") { + await BrowserTestUtils.waitForCondition( + () => { + let accessible = accService.getAccessibleFor(element); + return accessible !== null && accessible.name === expectedValue; + }, + description, + 200 + ); +} + +/** + * Initializes the accessibility service and registers a cleanup function to + * shut it down. If it's not shut down properly, it can crash the current tab + * and cause the test to fail, especially in verify mode. + * + * This function is adapted from from tests in accessible/tests/browser and its + * helper functions are adapted or copied from functions of the same names in + * the same directory. + */ +async function initAccessibilityService() { + const [a11yInitObserver, a11yInit] = initAccService(); + await a11yInitObserver; + accService = Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + await a11yInit; + + registerCleanupFunction(async () => { + const [a11yShutdownObserver, a11yShutdownPromise] = shutdownAccService(); + await a11yShutdownObserver; + accService = null; + forceGC(); + await a11yShutdownPromise; + }); +} + +// Adapted from `initAccService()` in accessible/tests/browser/head.js +function initAccService() { + return [ + CommonUtils.addAccServiceInitializedObserver(), + CommonUtils.observeAccServiceInitialized(), + ]; +} + +// Adapted from `shutdownAccService()` in accessible/tests/browser/head.js +function shutdownAccService() { + return [ + CommonUtils.addAccServiceShutdownObserver(), + CommonUtils.observeAccServiceShutdown(), + ]; +} + +// Copied from accessible/tests/browser/shared-head.js +function forceGC() { + SpecialPowers.gc(); + SpecialPowers.forceShrinkingGC(); + SpecialPowers.forceCC(); + SpecialPowers.gc(); + SpecialPowers.forceShrinkingGC(); + SpecialPowers.forceCC(); +} + +add_setup(async function () { + await initAccessibilityService(); +}); + +add_task(async function switchToTab() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "% robots", + }); + + let index = 0; + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Should have a switch tab result" + ); + + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + index + ); + // The a11y text will include the "Firefox Suggest" pseudo-element label shown + // before the result. + await getResultText( + element._content, + "Firefox Suggest about:robots — Switch to Tab", + "Result a11y text is correct" + ); + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + gBrowser.removeTab(tab); +}); + +add_task(async function searchSuggestions() { + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + Services.prefs.setBoolPref(SUGGEST_ALL_PREF, true); + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + registerCleanupFunction(async function () { + Services.prefs.clearUserPref(SUGGEST_ALL_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let length = await UrlbarTestUtils.getResultCount(window); + // Don't assume that the search doesn't match history or bookmarks left around + // by earlier tests. + Assert.greaterOrEqual( + length, + 3, + "Should get at least heuristic result + two search suggestions" + ); + // The first expected search is the search term itself since the heuristic + // result will come before the search suggestions. + let searchTerm = "foo"; + let expectedSearches = [searchTerm, "foofoo", "foobar"]; + for (let i = 0; i < length; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.type === UrlbarUtils.RESULT_TYPE.SEARCH) { + Assert.greaterOrEqual( + expectedSearches.length, + 0, + "Should still have expected searches remaining" + ); + + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + + // Select the row so we see the expanded text. + gURLBar.view.selectedRowIndex = i; + + if (result.searchParams.inPrivateWindow) { + await getResultText( + element._content, + searchTerm + " — Search in a Private Window", + "Check result label for search in private window" + ); + } else { + let suggestion = expectedSearches.shift(); + await getResultText( + element._content, + suggestion + + " — Search with browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Check result label for non-private search" + ); + } + } + } + Assert.ok(!expectedSearches.length); + + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js b/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js new file mode 100644 index 0000000000..ef3da56ef0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the first item is correctly autoselected and some navigation + * around the results list. + */ + +function repeat(limit, func) { + for (let i = 0; i < limit; i++) { + func(i); + } +} + +function assertSelected(index) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + index, + "Should have selected the correct item" + ); + + // This is true because although both the listbox and the one-offs can have + // selections, the test doesn't check that. + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton, + null, + "A result is selected, so the one-offs should not have a selection" + ); +} + +function assertSelected_one_off(index) { + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButtonIndex, + index, + "Expected one-off button should be selected" + ); + + // This is true because although both the listbox and the one-offs can have + // selections, the test doesn't check that. + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "A one-off is selected, so the listbox should not have a selection" + ); +} + +add_task(async function () { + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(tab); + }); + + let visits = []; + repeat(maxResults, i => { + visits.push({ + uri: makeURI("http://example.com/autocomplete/?" + i), + }); + }); + await PlacesTestUtils.addVisits(visits); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.com/autocomplete", + fireInputEvent: true, + }); + + let resultCount = await UrlbarTestUtils.getResultCount(window); + + Assert.equal( + resultCount, + maxResults, + "Should get the expected amount of results" + ); + assertSelected(0); + + info("Key Down to select the next item"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertSelected(1); + + info("Key Down maxResults-1 times should select the first one-off"); + repeat(maxResults - 1, () => EventUtils.synthesizeKey("KEY_ArrowDown")); + assertSelected_one_off(0); + + info("Key Down numButtons-1 should select the last one-off"); + let numButtons = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons( + true + ).length; + repeat(numButtons - 1, () => EventUtils.synthesizeKey("KEY_ArrowDown")); + assertSelected_one_off(numButtons - 1); + + info("Key Down twice more should select the second result"); + repeat(2, () => EventUtils.synthesizeKey("KEY_ArrowDown")); + assertSelected(1); + + info("Key Down maxResults + numButtons times should wrap around"); + repeat(maxResults + numButtons, () => + EventUtils.synthesizeKey("KEY_ArrowDown") + ); + assertSelected(1); + + info("Key Up maxResults + numButtons times should wrap around the other way"); + repeat(maxResults + numButtons, () => + EventUtils.synthesizeKey("KEY_ArrowUp") + ); + assertSelected(1); + + info("Page Up will go up the list, but not wrap"); + EventUtils.synthesizeKey("KEY_PageUp"); + assertSelected(0); + + info("Page Up again will wrap around to the end of the list"); + EventUtils.synthesizeKey("KEY_PageUp"); + assertSelected(maxResults - 1); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_cursor.js b/browser/components/urlbar/tests/browser/browser_autocomplete_cursor.js new file mode 100644 index 0000000000..5e0081a92c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_cursor.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the cursor remains in the right place when a new window is opened. + */ + +add_task(async function test_windowSwitch() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "www.mozilla.org", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + + gURLBar.focus(); + gURLBar.inputField.setSelectionRange(4, 4); + + let newWindow = await BrowserTestUtils.openNewBrowserWindow(); + + await BrowserTestUtils.closeWindow(newWindow); + + Assert.equal( + document.activeElement, + gURLBar.inputField, + "URL Bar should be focused" + ); + Assert.equal(gURLBar.selectionStart, 4, "Should not have moved the cursor"); + Assert.equal(gURLBar.selectionEnd, 4, "Should not have selected anything"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js b/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js new file mode 100644 index 0000000000..c17949eb9e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_edit_completed.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests selecting a result, and editing the value of that autocompleted result. + */ + +add_task(async function () { + await PlacesUtils.history.clear(); + + await PlacesTestUtils.addVisits([ + { uri: makeURI("http://example.com/foo") }, + { uri: makeURI("http://example.com/foo/bar") }, + ]); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "http://example.com", + }); + + const initialIndex = UrlbarTestUtils.getSelectedRowIndex(window); + + info("Key Down to select the next item."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + let nextIndex = initialIndex + 1; + let nextResult = await UrlbarTestUtils.getDetailsOfResultAt( + window, + nextIndex + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + nextIndex, + "Should have selected the next item" + ); + Assert.equal( + gURLBar.untrimmedValue, + nextResult.url, + "Should have completed the URL" + ); + + info("Press backspace"); + EventUtils.synthesizeKey("KEY_Backspace"); + await UrlbarTestUtils.promiseSearchComplete(window); + + let editedValue = gURLBar.value; + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + initialIndex, + "Should have selected the initialIndex again" + ); + Assert.notEqual(editedValue, nextResult.url, "The URL has changed."); + + let docLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + "http://" + editedValue, + gBrowser.selectedBrowser + ); + + info("Press return to load edited URL."); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + + await docLoad; +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_enter_race.js b/browser/components/urlbar/tests/browser/browser_autocomplete_enter_race.js new file mode 100644 index 0000000000..a684c60e5b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_enter_race.js @@ -0,0 +1,193 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests what happens when the enter key is pressed quickly after entering text. + */ + +// The order of these tests matters! + +add_setup(async function () { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/?q=%s", + title: "test", + }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + await PlacesUtils.history.clear(); + }); + // Needs at least one success. + ok(true, "Setup complete"); +}); + +add_task( + taskWithNewTab(async function test_loadSite() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autofill", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.co", + }); + gURLBar.focus(); + EventUtils.sendString("m"); + EventUtils.synthesizeKey("KEY_Enter"); + info("wait for the page to load"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedTab.linkedBrowser, + false, + "http://example.com/" + ); + await SpecialPowers.popPrefEnv(); + }) +); + +add_task( + taskWithNewTab(async function test_sametext() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.com", + fireInputEvent: true, + }); + + // Simulate re-entering the same text searched the last time. This may happen + // through a copy paste, but clipboard handling is not much reliable, so just + // fire an input event. + info("synthesize input event"); + let event = document.createEvent("Events"); + event.initEvent("input", true, true); + gURLBar.inputField.dispatchEvent(event); + EventUtils.synthesizeKey("KEY_Enter"); + + info("wait for the page to load"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedTab.linkedBrowser, + false, + "http://example.com/" + ); + }) +); + +add_task( + taskWithNewTab(async function test_after_empty_search() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + gURLBar.focus(); + gURLBar.value = "e"; + EventUtils.synthesizeKey("x"); + EventUtils.synthesizeKey("KEY_Enter"); + + info("wait for the page to load"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedTab.linkedBrowser, + false, + "http://example.com/" + ); + }) +); + +add_task( + taskWithNewTab(async function test_disabled_ac() { + // Disable autocomplete. + let suggestHistory = Preferences.get("browser.urlbar.suggest.history"); + Preferences.set("browser.urlbar.suggest.history", false); + let suggestBookmarks = Preferences.get("browser.urlbar.suggest.bookmark"); + Preferences.set("browser.urlbar.suggest.bookmark", false); + let suggestOpenPages = Preferences.get("browser.urlbar.suggest.openpage"); + Preferences.set("browser.urlbar.suggest.openpage", false); + + await SearchTestUtils.installSearchExtension(); + + let engine = Services.search.getEngineByName("Example"); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + async function cleanup() { + Preferences.set("browser.urlbar.suggest.history", suggestHistory); + Preferences.set("browser.urlbar.suggest.bookmark", suggestBookmarks); + Preferences.set("browser.urlbar.suggest.openpage", suggestOpenPages); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + } + registerCleanupFunction(cleanup); + + gURLBar.focus(); + gURLBar.value = "e"; + EventUtils.sendString("x"); + EventUtils.synthesizeKey("KEY_Enter"); + + info("wait for the page to load"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedTab.linkedBrowser, + false, + "https://example.com/?q=ex" + ); + await cleanup(); + }) +); + +// Tests that setting a high value for browser.urlbar.delay does not delay the +// fetching of heuristic results. +add_task( + taskWithNewTab(async function test_delay() { + // This is needed to clear the current value, otherwise autocomplete may think + // the user removed text from the end. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.promisePopupClose(window); + + // Set a large delay. + const TIMEOUT = 3000; + let delay = UrlbarPrefs.get("delay"); + UrlbarPrefs.set("delay", TIMEOUT); + registerCleanupFunction(function () { + UrlbarPrefs.set("delay", delay); + }); + + gURLBar.focus(); + gURLBar.value = "e"; + let recievedResult = new Promise(resolve => { + gURLBar.controller.addQueryListener({ + onQueryResults(queryContext) { + gURLBar.controller.removeQueryListener(this); + Assert.ok( + queryContext.heuristicResult, + "Recieved a heuristic result." + ); + Assert.equal( + queryContext.searchString, + "ex", + "The heuristic result is based on the correct search string." + ); + resolve(); + }, + }); + }); + let start = Cu.now(); + EventUtils.sendString("x"); + EventUtils.synthesizeKey("KEY_Enter"); + await recievedResult; + Assert.ok(Cu.now() - start < TIMEOUT); + }) +); + +// The main reason for running each test task in a new tab that's closed when +// the task finishes is to avoid switch-to-tab results. +function taskWithNewTab(fn) { + return async function () { + await BrowserTestUtils.withNewTab("about:blank", fn); + }; +} diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_no_title.js b/browser/components/urlbar/tests/browser/browser_autocomplete_no_title.js new file mode 100644 index 0000000000..fa30a7608a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_no_title.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we display just the domain name when a URL result doesn't + * have a title. + */ + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + await PlacesUtils.history.clear(); + const uri = "http://bug1060642.example.com/beards/are/pretty/great"; + await PlacesTestUtils.addVisits([{ uri, title: "" }]); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(tab); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bug1060642", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.displayed.title, + "bug1060642.example.com", + "Result title should be as expected" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_readline_navigation.js b/browser/components/urlbar/tests/browser/browser_autocomplete_readline_navigation.js new file mode 100644 index 0000000000..36f990503e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_readline_navigation.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests navigation between results using ctrl-n/p. + */ + +function repeat(limit, func) { + for (let i = 0; i < limit; i++) { + func(i); + } +} + +function assertSelected(index) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + index, + "Should have the correct item selected" + ); + + // This is true because although both the listbox and the one-offs can have + // selections, the test doesn't check that. + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton, + null, + "A result is selected, so the one-offs should not have a selection" + ); +} + +add_task(async function () { + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(tab); + }); + + let visits = []; + repeat(maxResults, i => { + visits.push({ + uri: makeURI("http://example.com/autocomplete/?" + i), + }); + }); + await PlacesTestUtils.addVisits(visits); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.com/autocomplete", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, maxResults - 1); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + "Should get maxResults=" + maxResults + " results" + ); + assertSelected(0); + + info("Ctrl-n to select the next item"); + EventUtils.synthesizeKey("n", { ctrlKey: true }); + assertSelected(1); + + info("Ctrl-p to select the previous item"); + EventUtils.synthesizeKey("p", { ctrlKey: true }); + assertSelected(0); +}); diff --git a/browser/components/urlbar/tests/browser/browser_autocomplete_tag_star_visibility.js b/browser/components/urlbar/tests/browser/browser_autocomplete_tag_star_visibility.js new file mode 100644 index 0000000000..10e26f6f71 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_tag_star_visibility.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for the bookmark star being correct displayed for results matching + * tags. + */ + +add_task(async function () { + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + async function addTagItem(tagName) { + let url = `http://example.com/this/is/tagged/${tagName}`; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: `test ${tagName}`, + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), [tagName]); + await PlacesTestUtils.addVisits({ + uri: url, + title: `Test page with tag ${tagName}`, + }); + } + + // We use different tags for each part of the test, as otherwise the + // autocomplete code tries to be smart by using the previously cached element + // without updating it (since all parameters it knows about are the same). + + let testcases = [ + { + description: "Test with suggest.bookmark=true", + tagName: "tagtest1", + prefs: { + "suggest.bookmark": true, + }, + input: "tagtest1", + expected: { + typeImageVisible: true, + }, + }, + { + description: "Test with suggest.bookmark=false", + tagName: "tagtest2", + prefs: { + "suggest.bookmark": false, + }, + input: "tagtest2", + expected: { + typeImageVisible: false, + }, + }, + { + description: "Test with suggest.bookmark=true (again)", + tagName: "tagtest3", + prefs: { + "suggest.bookmark": true, + }, + input: "tagtest3", + expected: { + typeImageVisible: true, + }, + }, + { + description: "Test with bookmark restriction token", + tagName: "tagtest4", + prefs: { + "suggest.bookmark": true, + }, + input: "* tagtest4", + expected: { + typeImageVisible: true, + }, + }, + { + description: "Test with history restriction token", + tagName: "tagtest5", + prefs: { + "suggest.bookmark": true, + }, + input: "^ tagtest5", + expected: { + typeImageVisible: false, + }, + }, + { + description: "Test partial tag and casing", + tagName: "tagtest6", + prefs: { + "suggest.bookmark": true, + }, + input: "TeSt6", + expected: { + typeImageVisible: true, + }, + }, + ]; + + for (let testcase of testcases) { + info(`Test case: ${testcase.description}`); + + await addTagItem(testcase.tagName); + for (let prefName of Object.keys(testcase.prefs)) { + Services.prefs.setBoolPref( + `browser.urlbar.${prefName}`, + testcase.prefs[prefName] + ); + } + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: testcase.input, + }); + + // If testcase.input triggers local search mode, there won't be a heuristic. + let resultIndex = + context.searchMode && !context.searchMode.engineName ? 0 : 1; + + Assert.greaterOrEqual( + UrlbarTestUtils.getResultCount(window), + resultIndex + 1, + `Should be at least ${resultIndex + 1} results` + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Should have a URL result type" + ); + // The Quantum Bar differs from the legacy urlbar in the fact that, if + // bookmarks are filtered out, it won't show tags for history results. + let expected_tags = !testcase.expected.typeImageVisible + ? [] + : [testcase.tagName]; + Assert.deepEqual( + result.tags, + expected_tags, + "Should have the expected tag" + ); + + if (testcase.expected.typeImageVisible) { + Assert.equal( + result.displayed.typeIcon, + 'url("chrome://browser/skin/bookmark-12.svg")', + "Should have the star image displayed or not as expected" + ); + } else { + Assert.equal( + result.displayed.typeIcon, + "none", + "Should have the star image displayed or not as expected" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_bestMatch.js b/browser/components/urlbar/tests/browser/browser_bestMatch.js new file mode 100644 index 0000000000..1e384e389f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_bestMatch.js @@ -0,0 +1,229 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests best match rows in the view. See also: +// +// browser_quicksuggest_bestMatch.js +// UI test for quick suggest best matches specifically +// test_quicksuggest_bestMatch.js +// Tests triggering quick suggest best matches and things that don't depend on +// the view + +"use strict"; + +// Tests a non-sponsored best match row. +add_task(async function nonsponsored() { + let result = makeBestMatchResult(); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a non-sponsored best match row with a help button. +add_task(async function nonsponsoredHelpButton() { + let result = makeBestMatchResult({ helpUrl: "https://example.com/help" }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, hasHelpUrl: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a sponsored best match row. +add_task(async function sponsored() { + let result = makeBestMatchResult({ isSponsored: true }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, isSponsored: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests a sponsored best match row with a help button. +add_task(async function sponsoredHelpButton() { + let result = makeBestMatchResult({ + isSponsored: true, + helpUrl: "https://example.com/help", + }); + await withProvider(result, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ result, isSponsored: true, hasHelpUrl: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests keyboard selection. +add_task(async function keySelection() { + let result = makeBestMatchResult({ + isSponsored: true, + helpUrl: "https://example.com/help", + }); + + await withProvider(result, async () => { + // Ordered list of class names of the elements that should be selected. + let expectedClassNames = [ + "urlbarView-row-inner", + UrlbarPrefs.get("resultMenu") + ? "urlbarView-button-menu" + : "urlbarView-button-help", + ]; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkBestMatchRow({ + result, + isSponsored: true, + hasHelpUrl: true, + }); + + // Test with the tab key in order vs. reverse order. + for (let reverse of [false, true]) { + info("Doing TAB key selection: " + JSON.stringify({ reverse })); + + let classNames = [...expectedClassNames]; + if (reverse) { + classNames.reverse(); + } + + let sendKey = () => { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: reverse }); + }; + + // Move selection through each expected element. + for (let className of classNames) { + info("Expecting selection: " + className); + sendKey(); + Assert.ok(gURLBar.view.isOpen, "View remains open"); + let { selectedElement } = gURLBar.view; + Assert.ok(selectedElement, "Selected element exists"); + Assert.ok( + selectedElement.classList.contains(className), + "Expected element is selected" + ); + } + sendKey(); + Assert.ok( + gURLBar.view.isOpen, + "View remains open after keying through best match row" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +async function checkBestMatchRow({ + result, + isSponsored = false, + hasHelpUrl = false, +}) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "One result is present" + ); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let { row } = details.element; + + Assert.equal(row.getAttribute("type"), "bestmatch", "row[type] is bestmatch"); + + let favicon = row._elements.get("favicon"); + Assert.ok(favicon, "Row has a favicon"); + + let title = row._elements.get("title"); + Assert.ok(title, "Row has a title"); + Assert.ok(title.textContent, "Row title has non-empty textContext"); + Assert.equal(title.textContent, result.payload.title, "Row title is correct"); + + let url = row._elements.get("url"); + Assert.ok(url, "Row has a URL"); + Assert.ok(url.textContent, "Row URL has non-empty textContext"); + Assert.equal( + url.textContent, + result.payload.displayUrl, + "Row URL is correct" + ); + + let bottom = row._elements.get("bottom"); + Assert.ok(bottom, "Row has a bottom"); + Assert.equal( + !!result.payload.isSponsored, + isSponsored, + "Sanity check: Row's expected isSponsored matches result's" + ); + if (isSponsored) { + Assert.equal( + bottom.textContent, + "Sponsored", + "Sponsored row bottom has Sponsored textContext" + ); + } else { + Assert.equal( + bottom.textContent, + "", + "Non-sponsored row bottom has empty textContext" + ); + } + + let button = row._buttons.get( + UrlbarPrefs.get("resultMenu") ? "menu" : "help" + ); + Assert.equal( + !!result.payload.helpUrl, + hasHelpUrl, + "Sanity check: Row's expected hasHelpUrl matches result" + ); + if (hasHelpUrl) { + Assert.ok(button, "Row with helpUrl has a help or menu button"); + } else { + Assert.ok( + !button, + "Row without helpUrl does not have a help or menu button" + ); + } +} + +async function withProvider(result, callback) { + let provider = new UrlbarTestUtils.TestProvider({ + results: [result], + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + try { + await callback(); + } finally { + UrlbarProvidersManager.unregisterProvider(provider); + } +} + +function makeBestMatchResult(payloadExtra = {}) { + return Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights([], { + title: "Test best match", + url: "https://example.com/best-match", + ...payloadExtra, + }) + ), + { isBestMatch: true } + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_blanking.js b/browser/components/urlbar/tests/browser/browser_blanking.js new file mode 100644 index 0000000000..eabaa2575d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_blanking.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}file_blank_but_not_blank.html`; + +add_task(async function () { + for (let page of gInitialPages) { + if (page == "about:newtab") { + // New tab preloading makes this a pain to test, so skip + continue; + } + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, page); + ok( + !gURLBar.value, + "The URL bar should be empty if we load a plain " + page + " page." + ); + BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function () { + // The test was originally to check that reloading of a javascript: URL could + // throw an error and empty the URL bar. This situation can no longer happen + // as in bug 836567 we set document.URL to active document's URL on navigation + // to a javascript: URL; reloading after that will simply reload the original + // active document rather than the javascript: URL itself. But we can still + // verify that the URL bar's value is correct. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + is(gURLBar.value, TEST_URL, "The URL bar should match the URI"); + let browserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + SpecialPowers.spawn(tab.linkedBrowser, [], function () { + content.document.querySelector("a").click(); + }); + await browserLoaded; + is( + gURLBar.value, + TEST_URL, + "The URL bar should be the previous active document's URI." + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + // This is sync, so by the time we return we should have changed the URL bar. + content.location.reload(); + }).catch(e => { + // Ignore expected exception. + }); + is( + gURLBar.value, + TEST_URL, + "The URL bar should still be the previous active document's URI." + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_bufferer_onQueryResults.js b/browser/components/urlbar/tests/browser/browser_bufferer_onQueryResults.js new file mode 100644 index 0000000000..7325d44b2c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_bufferer_onQueryResults.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// This test covers a race condition of input events followed by Enter. +// The test is putting the event bufferer in a situation where a new query has +// already results in the context object, but onQueryResults has not been +// invoked yet. The EventBufferer should wait for onQueryResults to proceed, +// otherwise the view cannot yet contain the updated query string and we may +// end up searching for a partial string. + +add_setup(async function () { + sandbox = sinon.createSandbox(); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + // To reproduce the race condition it's important to disable any provider + // having `deferUserSelection` == true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.engines", false]], + }); + await PlacesUtils.history.clear(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + sandbox.restore(); + }); +}); + +add_task(async function test() { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:robots", + }); + + let defer = PromiseUtils.defer(); + let waitFirstSearchResults = PromiseUtils.defer(); + let count = 0; + let original = gURLBar.controller.notify; + sandbox.stub(gURLBar.controller, "notify").callsFake(async (msg, context) => { + if (context?.deferUserSelectionProviders.size) { + Assert.ok(false, "Any provider deferring selection should be disabled"); + } + if (msg == "onQueryResults") { + waitFirstSearchResults.resolve(); + count++; + } + // Delay any events after the second onQueryResults call. + if (count >= 2) { + await defer.promise; + } + return original.call(gURLBar.controller, msg, context); + }); + + gURLBar.focus(); + gURLBar.select(); + EventUtils.synthesizeKey("t", {}); + await waitFirstSearchResults.promise; + EventUtils.synthesizeKey("e", {}); + + let promiseLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter", {}); + + let context = await UrlbarTestUtils.promiseSearchComplete(window); + await TestUtils.waitForCondition( + () => context.results.length, + "Waiting for any result in the QueryContext" + ); + info("Simulate a request to replay deferred events at this point"); + gURLBar.eventBufferer.replayDeferredEvents(true); + + defer.resolve(); + await promiseLoaded; + + let expectedURL = UrlbarPrefs.isPersistedSearchTermsEnabled() + ? "http://mochi.test:8888/?terms=" + gURLBar.value + : gURLBar.untrimmedValue; + Assert.equal(gBrowser.selectedBrowser.currentURI.spec, expectedURL); + + BrowserTestUtils.removeTab(tab); + sandbox.restore(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_calculator.js b/browser/components/urlbar/tests/browser/browser_calculator.js new file mode 100644 index 0000000000..899cbc6d5b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_calculator.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const FORMULA = "8 * 8"; +const RESULT = "64"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.calculator", true]], + }); +}); + +add_task(async function test_calculator() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: FORMULA, + }); + + let result = (await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1)) + .result; + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.payload.input, FORMULA); + Assert.equal(result.payload.value, RESULT); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Ensure the RESULT get written to the clipboard when selected. + await SimpleTest.promiseClipboardChange(RESULT, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_canonizeURL.js b/browser/components/urlbar/tests/browser/browser_canonizeURL.js new file mode 100644 index 0000000000..04f153e7d2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_canonizeURL.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests turning non-url-looking values typed in the input field into proper URLs. + */ + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +add_task(async function checkCtrlWorks() { + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + let defaultEngine = await Services.search.getDefault(); + let testcases = [ + ["example", "https://www.example.com/", { ctrlKey: true }], + // Check that a direct load is not overwritten by a previous canonization. + ["http://example.com/test/", "http://example.com/test/", {}], + ["ex-ample", "https://www.ex-ample.com/", { ctrlKey: true }], + [" example ", "https://www.example.com/", { ctrlKey: true }], + [" example/foo ", "https://www.example.com/foo", { ctrlKey: true }], + [ + " example/foo bar ", + "https://www.example.com/foo%20bar", + { ctrlKey: true }, + ], + ["example.net", "http://example.net/", { ctrlKey: true }], + ["http://example", "http://example/", { ctrlKey: true }], + ["example:8080", "http://example:8080/", { ctrlKey: true }], + ["ex-ample.foo", "http://ex-ample.foo/", { ctrlKey: true }], + ["example.foo/bar ", "http://example.foo/bar", { ctrlKey: true }], + ["1.1.1.1", "http://1.1.1.1/", { ctrlKey: true }], + ["ftp.example.bar", "http://ftp.example.bar/", { ctrlKey: true }], + [ + "ex ample", + defaultEngine.getSubmission("ex ample", null, "keyword").uri.spec, + { ctrlKey: true }, + ], + ]; + + // Disable autoFill for this test, since it could mess up the results. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", false], + ["browser.urlbar.ctrlCanonizesURLs", true], + ], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + for (let [inputValue, expectedURL, options] of testcases) { + info(`Testing input string: "${inputValue}" - expected: "${expectedURL}"`); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURL, + win.gBrowser.selectedBrowser + ); + let promiseStopped = BrowserTestUtils.browserStopped( + win.gBrowser.selectedBrowser, + undefined, + true + ); + win.gURLBar.focus(); + win.gURLBar.inputField.value = inputValue.slice(0, -1); + EventUtils.sendString(inputValue.slice(-1), win); + EventUtils.synthesizeKey("KEY_Enter", options, win); + await Promise.all([promiseLoad, promiseStopped]); + } + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function checkPrefTurnsOffCanonize() { + // Add a dummy search engine to avoid hitting the network. + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Ensure we don't end up loading something in the current tab becuase it's empty: + let initialTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + opening: "about:mozilla", + }); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.ctrlCanonizesURLs", false]], + }); + + let newURL = "http://mochi.test:8888/?terms=example"; + // On MacOS CTRL+Enter is not supposed to open in a new tab, because it uses + // CMD+Enter for that. + let promiseLoaded = + AppConstants.platform == "macosx" + ? BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + newURL + ) + : BrowserTestUtils.waitForNewTab(win.gBrowser); + + win.gURLBar.focus(); + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.inputField.value.length; + win.gURLBar.inputField.value = "exampl"; + EventUtils.sendString("e", win); + EventUtils.synthesizeKey("KEY_Enter", { ctrlKey: true }, win); + + await promiseLoaded; + if (AppConstants.platform == "macosx") { + Assert.equal( + initialTab.linkedBrowser.currentURI.spec, + newURL, + "Original tab should have navigated" + ); + } else { + Assert.equal( + initialTab.linkedBrowser.currentURI.spec, + "about:mozilla", + "Original tab shouldn't have navigated" + ); + Assert.equal( + win.gBrowser.selectedBrowser.currentURI.spec, + newURL, + "New tab should have navigated" + ); + } + while (win.gBrowser.tabs.length > 1) { + win.gBrowser.removeTab(win.gBrowser.selectedTab, { animate: false }); + } + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function autofill() { + // Re-enable autofill and canonization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + ["browser.urlbar.ctrlCanonizesURLs", true], + ], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Quantumbar automatically disables autofill when the old search string + // starts with the new search string, so to make sure that doesn't happen and + // that earlier tests don't conflict with this one, start a new search for + // some other string. + win.gURLBar.select(); + EventUtils.sendString("blah", win); + + // Add a visit that will be autofilled. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + let testcases = [ + ["ex", "https://www.ex.com/", { ctrlKey: true }], + // Check that a direct load is not overwritten by a previous canonization. + ["ex", "http://example.com/", {}], + // search alias + ["@goo", "https://www.goo.com/", { ctrlKey: true }], + ]; + + function promiseAutofill() { + return BrowserTestUtils.waitForEvent(win.gURLBar.inputField, "select"); + } + + for (let [inputValue, expectedURL, options] of testcases) { + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURL, + win.gBrowser.selectedBrowser + ); + win.gURLBar.select(); + let autofillPromise = promiseAutofill(); + EventUtils.sendString(inputValue, win); + await autofillPromise; + EventUtils.synthesizeKey("KEY_Enter", options, win); + await promiseLoad; + + // Here again, make sure autofill isn't disabled for the next search. See + // the comment above. + win.gURLBar.select(); + EventUtils.sendString("blah", win); + } + + await PlacesUtils.history.clear(); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function () { + info( + "Test whether canonization is disabled until the ctrl key is releasing if the key was used to paste text into urlbar" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.ctrlCanonizesURLs", true]], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Paste the word to the urlbar"); + const testWord = "example"; + simulatePastingToUrlbar(testWord, win); + is(win.gURLBar.value, testWord, "Paste the test word correctly"); + + info("Send enter key while pressing the ctrl key"); + EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, win); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + is( + win.gBrowser.selectedBrowser.documentURI.spec, + `http://mochi.test:8888/?terms=${testWord}`, + "The loaded url is not canonized" + ); + EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" }, win); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function () { + info("Test whether canonization is enabled again after releasing the ctrl"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.ctrlCanonizesURLs", true]], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Paste the word to the urlbar"); + const testWord = "example"; + simulatePastingToUrlbar(testWord, win); + is(win.gURLBar.value, testWord, "Paste the test word correctly"); + + info("Release the ctrl key befoer typing Enter key"); + EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" }, win); + + info("Send enter key with the ctrl"); + const onLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + `https://www.${testWord}.com/`, + win.gBrowser.selectedBrowser + ); + const onStop = BrowserTestUtils.browserStopped( + win.gBrowser.selectedBrowser, + undefined, + true + ); + EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, win); + await Promise.all([onLoad, onStop]); + info("The loaded url is canonized"); + + await BrowserTestUtils.closeWindow(win); +}); + +function simulatePastingToUrlbar(text, win) { + win.gURLBar.focus(); + + const keyForPaste = win.document + .getElementById("key_paste") + .getAttribute("key") + .toLowerCase(); + EventUtils.synthesizeKey( + keyForPaste, + { type: "keydown", ctrlKey: true }, + win + ); + + win.gURLBar.select(); + EventUtils.sendString(text, win); +} diff --git a/browser/components/urlbar/tests/browser/browser_caret_position.js b/browser/components/urlbar/tests/browser/browser_caret_position.js new file mode 100644 index 0000000000..35eee55efe --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_caret_position.js @@ -0,0 +1,359 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const LARGE_DATA_URL = + "data:text/plain," + [...Array(1000)].map(() => "0123456789").join(""); + +// Tests for the caret position after gURLBar.setURI(). +add_task(async function setURI() { + const testData = [ + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/test", + initialSelectionStart: 0, + initialSelectionEnd: 0, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/test", + initialSelectionStart: 20, + initialSelectionEnd: 20, + expectedSelectionStart: 20, + expectedSelectionEnd: 20, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/test", + initialSelectionStart: 1, + initialSelectionEnd: 20, + expectedSelectionStart: 1, + expectedSelectionEnd: 20, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/test", + initialSelectionStart: "https://example.com/test".length, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: "https://example.com/test".length, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/test", + initialSelectionStart: 0, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: 0, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: 0, + initialSelectionEnd: 0, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: 20, + initialSelectionEnd: 20, + expectedSelectionStart: 20, + expectedSelectionEnd: 20, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: 1, + initialSelectionEnd: 10, + expectedSelectionStart: 1, + expectedSelectionEnd: 10, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: "https://example.".length, + initialSelectionEnd: "https://example.c".length, + expectedSelectionStart: "https://example.c".length, + expectedSelectionEnd: "https://example.c".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: "https://example.com/test".length, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: "https://example.org/test".length, + expectedSelectionEnd: "https://example.org/test".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.org/test", + initialSelectionStart: 0, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: "https://example.org/test".length, + expectedSelectionEnd: "https://example.org/test".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/longer", + initialSelectionStart: "https://example.com/test".length, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: "https://example.com/longer".length, + expectedSelectionEnd: "https://example.com/longer".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "https://example.com/longer", + initialSelectionStart: 20, + initialSelectionEnd: 20, + expectedSelectionStart: 20, + expectedSelectionEnd: 20, + }, + { + firstURL: "https://example.com/longer", + secondURL: "https://example.com/test", + initialSelectionStart: 0, + initialSelectionEnd: "https://example.com/longer".length, + expectedSelectionStart: "https://example.com/test".length, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/longer", + secondURL: "https://example.com/test", + initialSelectionStart: "https://example.com/longer".length, + initialSelectionEnd: "https://example.com/longer".length, + expectedSelectionStart: "https://example.com/test".length, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/longer", + secondURL: "https://example.com/test", + initialSelectionStart: "https://example.com/longer".length - 1, + initialSelectionEnd: "https://example.com/longer".length - 1, + expectedSelectionStart: "https://example.com/test".length, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/longer", + secondURL: "https://example.com/test", + initialSelectionStart: 0, + initialSelectionEnd: "https://example.com/longer".length - 1, + expectedSelectionStart: "https://example.com/test".length, + expectedSelectionEnd: "https://example.com/test".length, + }, + { + firstURL: "https://example.com/test", + secondURL: "about:blank", + initialSelectionStart: 0, + initialSelectionEnd: 0, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "https://example.com/test", + secondURL: "about:blank", + initialSelectionStart: 0, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "https://example.com/test", + secondURL: "about:blank", + initialSelectionStart: 3, + initialSelectionEnd: 4, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "https://example.com/test", + secondURL: "about:blank", + initialSelectionStart: "https://example.com/test".length, + initialSelectionEnd: "https://example.com/test".length, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "about:blank", + secondURL: "https://example.com/test", + initialSelectionStart: 0, + initialSelectionEnd: 0, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "about:blank", + secondURL: LARGE_DATA_URL, + initialSelectionStart: 0, + initialSelectionEnd: 0, + expectedSelectionStart: 0, + expectedSelectionEnd: 0, + }, + { + firstURL: "about:telemetry", + secondURL: LARGE_DATA_URL, + initialSelectionStart: "about:telemetry".length, + initialSelectionEnd: "about:telemetry".length, + expectedSelectionStart: LARGE_DATA_URL.length, + expectedSelectionEnd: LARGE_DATA_URL.length, + }, + ]; + + for (const data of testData) { + info( + `Test for ${data.firstURL} -> ${data.secondURL} with initial selection: ${data.initialSelectionStart}, ${data.initialSelectionEnd}` + ); + info("Check the caret position after setting second URL"); + gURLBar.setURI(makeURI(data.firstURL)); + gURLBar.selectionStart = data.initialSelectionStart; + gURLBar.selectionEnd = data.initialSelectionEnd; + + // The change of the scroll amount dependent on the selection change will be + // ignored if the previous processing is unfinished yet. Therefore, make the + // processing finalize explicitly here. + await flushScrollStyle(); + + gURLBar.focus(); + gURLBar.setURI(makeURI(data.secondURL)); + await flushScrollStyle(); + + Assert.equal(gURLBar.selectionStart, data.expectedSelectionStart); + Assert.equal(gURLBar.selectionEnd, data.expectedSelectionEnd); + if (data.secondURL.length === data.expectedSelectionStart) { + // If the caret is at the end of url, the input field shows the end of + // text. + Assert.equal( + gURLBar.inputField.scrollLeft, + gURLBar.inputField.scrollLeftMax + ); + } + + info("Check the caret position while the input is not focused"); + gURLBar.setURI(makeURI(data.firstURL)); + gURLBar.selectionStart = data.initialSelectionStart; + gURLBar.selectionEnd = data.initialSelectionEnd; + + await flushScrollStyle(); + + gURLBar.blur(); + gURLBar.setURI(makeURI(data.secondURL)); + await flushScrollStyle(); + + if (data.firstURL === data.secondURL) { + Assert.equal(gURLBar.selectionStart, data.initialSelectionStart); + Assert.equal(gURLBar.selectionEnd, data.initialSelectionEnd); + } else { + Assert.equal(gURLBar.selectionStart, gURLBar.value.length); + Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); + } + Assert.equal(gURLBar.inputField.scrollLeft, 0); + } +}); + +// Tests that up and down keys move the caret on certain platforms, and that +// opening the popup doesn't change the caret position. +add_task(async function navigation() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "This is a generic sentence", + }); + await UrlbarTestUtils.promisePopupClose(window); + + const INITIAL_SELECTION_START = 3; + const INITIAL_SELECTION_END = 10; + gURLBar.selectionStart = INITIAL_SELECTION_START; + gURLBar.selectionEnd = INITIAL_SELECTION_END; + + if (AppConstants.platform == "macosx" || AppConstants.platform == "linux") { + await checkCaretMoves( + "KEY_ArrowDown", + gURLBar.value.length, + "Caret should have moved to the end", + window + ); + await checkPopupOpens("KEY_ArrowDown", window); + + await checkCaretMoves( + "KEY_ArrowUp", + 0, + "Caret should have moved to the start", + window + ); + await checkPopupOpens("KEY_ArrowUp", window); + } else { + await checkPopupOpens("KEY_ArrowDown", window); + await checkPopupOpens("KEY_ArrowUp", window); + } +}); + +async function checkCaretMoves(key, pos, msg, win) { + checkIfKeyStartsQuery(key, false, win); + Assert.equal( + UrlbarTestUtils.isPopupOpen(win), + false, + `${key}: Popup shouldn't be open` + ); + Assert.equal( + win.gURLBar.selectionStart, + win.gURLBar.selectionEnd, + `${key}: Input selection should be empty` + ); + Assert.equal(win.gURLBar.selectionStart, pos, `${key}: ${msg}`); +} + +async function checkPopupOpens(key, win) { + // Store current selection and check it doesn't change. + let selectionStart = win.gURLBar.selectionStart; + let selectionEnd = win.gURLBar.selectionEnd; + await UrlbarTestUtils.promisePopupOpen(win, () => { + checkIfKeyStartsQuery(key, true, win); + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(win), + 0, + `${key}: Heuristic result should be selected` + ); + Assert.equal( + win.gURLBar.selectionStart, + selectionStart, + `${key}: Input selection start should not change` + ); + Assert.equal( + win.gURLBar.selectionEnd, + selectionEnd, + `${key}: Input selection end should not change` + ); + await UrlbarTestUtils.promisePopupClose(win); +} + +function checkIfKeyStartsQuery(key, shouldStartQuery, win) { + let queryStarted = false; + let queryListener = { + onQueryStarted() { + queryStarted = true; + }, + }; + win.gURLBar.controller.addQueryListener(queryListener); + EventUtils.synthesizeKey(key, {}, win); + win.gURLBar.eventBufferer.replayDeferredEvents(false); + win.gURLBar.controller.removeQueryListener(queryListener); + Assert.equal( + queryStarted, + shouldStartQuery, + `${key}: Should${shouldStartQuery ? "" : "n't"} have started a query` + ); +} + +async function flushScrollStyle() { + // Flush pending notifications for the style. + /* eslint-disable no-unused-expressions */ + gURLBar.inputField.scrollLeft; + // Ensure to apply the style. + await new Promise(resolve => + gURLBar.inputField.ownerGlobal.requestAnimationFrame(resolve) + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_click_row_border.js b/browser/components/urlbar/tests/browser/browser_click_row_border.js new file mode 100644 index 0000000000..59915ed3b1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_click_row_border.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = "https://example.com/autocomplete"; + +add_setup(async function () { + await PlacesTestUtils.addVisits(TEST_URL); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_click_row_border() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.com/autocomplete", + }); + let resultRow = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + let loaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + info("Clicking on the result's top pixel row"); + EventUtils.synthesizeMouse( + resultRow, + parseInt(getComputedStyle(resultRow).borderTopLeftRadius) * 2, + 1, + {} + ); + info("Waiting for page to load"); + await loaded; + ok(true, "Page loaded"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_closePanelOnClick.js b/browser/components/urlbar/tests/browser/browser_closePanelOnClick.js new file mode 100644 index 0000000000..2bbe412acb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_closePanelOnClick.js @@ -0,0 +1,34 @@ +/* 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/. */ + +/** + * This tests that the urlbar panel closes when clicking certain ui elements. + */ + +"use strict"; + +add_task(async function () { + await BrowserTestUtils.withNewTab("about:robots", async () => { + for (let elt of [ + gBrowser.selectedBrowser, + gBrowser.tabContainer, + document.querySelector("#nav-bar toolbarspring"), + ]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "dummy", + }); + // Must have at least one test. + Assert.ok(!!elt, "Found a valid element: " + (elt.id || elt.localName)); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeNativeMouseEvent({ + type: "click", + target: elt, + atCenter: true, + }) + ); + } + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_content_opener.js b/browser/components/urlbar/tests/browser/browser_content_opener.js new file mode 100644 index 0000000000..0cf4865ad7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_content_opener.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + TEST_BASE_URL + "dummy_page.html", + async function (browser) { + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + await SpecialPowers.spawn(browser, [], function () { + content.window.open("", "_BLANK", "toolbar=no,height=300,width=500"); + }); + let newWin = await windowOpenedPromise; + is( + newWin.gURLBar.value, + "about:blank", + "Should be displaying about:blank for the opened window." + ); + await BrowserTestUtils.closeWindow(newWin); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_contextualsearch.js b/browser/components/urlbar/tests/browser/browser_contextualsearch.js new file mode 100644 index 0000000000..5de1673e6a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_contextualsearch.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { UrlbarProviderContextualSearch } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderContextualSearch.sys.mjs" +); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.contextualSearch.enabled", true]], + }); +}); + +add_task(async function test_selectContextualSearchResult_already_installed() { + await SearchTestUtils.installSearchExtension({ + name: "Contextual", + search_url: "https://example.com/browser", + }); + + const ENGINE_TEST_URL = "https://example.com/"; + let onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + ENGINE_TEST_URL + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ENGINE_TEST_URL); + await onLoaded; + + const query = "search"; + let engine = Services.search.getEngineByName("Contextual"); + const [expectedUrl] = UrlbarUtils.getSearchQueryUrl(engine, query); + + Assert.ok( + expectedUrl.includes(`?q=${query}`), + "Expected URL should be a search URL" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + const resultIndex = UrlbarTestUtils.getResultCount(window) - 1; + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + + is( + result.dynamicType, + "contextualSearch", + "Second last result is a contextual search result" + ); + + info("Focus and select the contextual search result"); + UrlbarTestUtils.setSelectedRowIndex(window, resultIndex); + let onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedUrl + ); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + expectedUrl, + "Selecting the contextual search result opens the search URL" + ); +}); + +add_task(async function test_selectContextualSearchResult_not_installed() { + const ENGINE_TEST_URL = + "http://mochi.test:8888/browser/browser/components/search/test/browser/opensearch.html"; + const EXPECTED_URL = + "http://mochi.test:8888/browser/browser/components/search/test/browser/?search&test=search"; + let onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + ENGINE_TEST_URL + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ENGINE_TEST_URL); + await onLoaded; + + const query = "search"; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + const resultIndex = UrlbarTestUtils.getResultCount(window) - 1; + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + + Assert.equal( + result.dynamicType, + "contextualSearch", + "Second last result is a contextual search result" + ); + + info("Focus and select the contextual search result"); + UrlbarTestUtils.setSelectedRowIndex(window, resultIndex); + let onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + EXPECTED_URL + ); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + EXPECTED_URL, + "Selecting the contextual search result opens the search URL" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_copy_during_load.js b/browser/components/urlbar/tests/browser/browser_copy_during_load.js new file mode 100644 index 0000000000..4a81ff08be --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_copy_during_load.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that copying from the urlbar page works correctly after a result is +// confirmed but takes a while to load. + +add_task(async function () { + const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://www.example.com" + ) + "slow-page.sjs"; + + await BrowserTestUtils.withNewTab(gBrowser, async tab => { + gURLBar.focus(); + gURLBar.value = SLOW_PAGE; + let promise = TestUtils.waitForCondition( + () => gURLBar.getAttribute("pageproxystate") == "invalid" + ); + EventUtils.synthesizeKey("KEY_Enter"); + info("wait for the initial conditions"); + await promise; + + info("Copy the whole url"); + await SimpleTest.promiseClipboardChange(SLOW_PAGE, () => { + gURLBar.select(); + goDoCommand("cmd_copy"); + }); + + info("Copy the initial part of the url, as a different valid url"); + await SimpleTest.promiseClipboardChange( + SLOW_PAGE.substring(0, SLOW_PAGE.indexOf("slow-page.sjs")), + () => { + gURLBar.selectionStart = 0; + gURLBar.selectionEnd = gURLBar.value.indexOf("slow-page.sjs"); + goDoCommand("cmd_copy"); + } + ); + + // This is apparently necessary to avoid a timeout on mochitest shutdown(!?) + let browserStoppedPromise = BrowserTestUtils.browserStopped( + gBrowser, + null, + true + ); + BrowserStop(); + await browserStoppedPromise; + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_copying.js b/browser/components/urlbar/tests/browser/browser_copying.js new file mode 100644 index 0000000000..9c32115fb4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_copying.js @@ -0,0 +1,416 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function getUrl(hostname, file) { + return ( + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + hostname + ) + file + ); +} + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + registerCleanupFunction(function () { + gBrowser.removeTab(tab); + gURLBar.setURI(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.trimURLs", true], + // avoid prompting about phishing + ["network.http.phishy-userpass-length", 32], + ], + }); + + for (let testCase of tests) { + if (testCase.setup) { + await testCase.setup(); + } + + if (testCase.loadURL) { + info(`Loading : ${testCase.loadURL}`); + let expectedLoad = testCase.expectedLoad || testCase.loadURL; + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + testCase.loadURL + ); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedLoad + ); + } else if (testCase.setURL) { + gURLBar.value = testCase.setURL; + } + if (testCase.setURL || testCase.loadURL) { + gURLBar.valueIsTyped = !!testCase.setURL; + is( + gURLBar.value, + testCase.expectedURL, + "url bar value set to " + gURLBar.value + ); + } + + gURLBar.focus(); + if (testCase.expectedValueOnFocus) { + Assert.equal( + gURLBar.value, + testCase.expectedValueOnFocus, + "Check value on focus" + ); + } + await testCopy(testCase.copyVal, testCase.copyExpected); + gURLBar.blur(); + + if (testCase.cleanup) { + await testCase.cleanup(); + } + } +}); + +var tests = [ + // pageproxystate="invalid" + { + setURL: "http://example.com/", + expectedURL: "example.com", + copyExpected: "example.com", + }, + { + copyVal: "<e>xample.com", + copyExpected: "e", + }, + { + copyVal: "<e>x<a>mple.com", + copyExpected: "ea", + }, + { + copyVal: "<e><xa>mple.com", + copyExpected: "exa", + }, + { + copyVal: "<e><xa>mple.co<m>", + copyExpected: "exam", + }, + { + copyVal: "<e><xample.co><m>", + copyExpected: "example.com", + }, + + // pageproxystate="valid" from this point on (due to the load) + { + loadURL: "http://example.com/", + expectedURL: "example.com", + copyExpected: "http://example.com/", + }, + { + copyVal: "<example.co>m", + copyExpected: "example.co", + }, + { + copyVal: "e<x>ample.com", + copyExpected: "x", + }, + { + copyVal: "<e>xample.com", + copyExpected: "e", + }, + { + copyVal: "<e>xample.co<m>", + copyExpected: "em", + }, + { + copyVal: "<exam><ple.com>", + copyExpected: "example.com", + }, + + { + loadURL: "http://example.com/foo", + expectedURL: "example.com/foo", + copyExpected: "http://example.com/foo", + }, + { + copyVal: "<example.com>/foo", + copyExpected: "http://example.com", + }, + { + copyVal: "<example>.com/foo", + copyExpected: "example", + }, + // Test that partially selected URL is copied with encoded spaces + { + loadURL: "http://example.com/%20space/test", + expectedURL: "example.com/ space/test", + copyExpected: "http://example.com/%20space/test", + }, + { + copyVal: "<example.com/ space>/test", + copyExpected: "http://example.com/%20space", + }, + { + copyVal: "<example.com/ space/test>", + copyExpected: "http://example.com/%20space/test", + }, + { + loadURL: "http://example.com/%20foo%20bar%20baz/", + expectedURL: "example.com/ foo bar baz/", + copyExpected: "http://example.com/%20foo%20bar%20baz/", + }, + { + copyVal: "<example.com/ foo bar> baz/", + copyExpected: "http://example.com/%20foo%20bar", + }, + { + copyVal: "example.<com/ foo bar> baz/", + copyExpected: "com/ foo bar", + }, + + // Test that userPass is stripped out + { + loadURL: getUrl( + "http://user:pass@mochi.test:8888", + "authenticate.sjs?user=user&pass=pass" + ), + expectedURL: getUrl( + "mochi.test:8888", + "authenticate.sjs?user=user&pass=pass" + ), + copyExpected: getUrl( + "http://mochi.test:8888", + "authenticate.sjs?user=user&pass=pass" + ), + }, + + // Test escaping + { + loadURL: "http://example.com/()%28%29%C3%A9", + expectedURL: "example.com/()()\xe9", + copyExpected: "http://example.com/()%28%29%C3%A9", + }, + { + copyVal: "<example.com/(>)()\xe9", + copyExpected: "http://example.com/(", + }, + { + copyVal: "e<xample.com/(>)()\xe9", + copyExpected: "xample.com/(", + }, + + { + loadURL: "http://example.com/%C3%A9%C3%A9", + expectedURL: "example.com/\xe9\xe9", + copyExpected: "http://example.com/%C3%A9%C3%A9", + }, + { + copyVal: "e<xample.com/\xe9>\xe9", + copyExpected: "xample.com/\xe9", + }, + { + copyVal: "<example.com/\xe9>\xe9", + copyExpected: "http://example.com/%C3%A9", + }, + { + // Note: it seems BrowserTestUtils.loadURI fails for unicode domains + loadURL: "http://sub2.xn--lt-uia.mochi.test:8888/foo", + expectedURL: "sub2.ält.mochi.test:8888/foo", + copyExpected: "http://sub2.ält.mochi.test:8888/foo", + }, + { + copyVal: "s<ub2.ält.mochi.test:8888/f>oo", + copyExpected: "ub2.ält.mochi.test:8888/f", + }, + { + copyVal: "<sub2.ält.mochi.test:8888/f>oo", + copyExpected: "http://sub2.%C3%A4lt.mochi.test:8888/f", + }, + + { + loadURL: "http://example.com/?%C3%B7%C3%B7", + expectedURL: "example.com/?\xf7\xf7", + copyExpected: "http://example.com/?%C3%B7%C3%B7", + }, + { + copyVal: "e<xample.com/?\xf7>\xf7", + copyExpected: "xample.com/?\xf7", + }, + { + copyVal: "<example.com/?\xf7>\xf7", + copyExpected: "http://example.com/?%C3%B7", + }, + { + loadURL: "http://example.com/a%20test", + expectedURL: "example.com/a test", + copyExpected: "http://example.com/a%20test", + }, + { + loadURL: "http://example.com/a%E3%80%80test", + expectedURL: "example.com/a%E3%80%80test", + copyExpected: "http://example.com/a%E3%80%80test", + }, + { + loadURL: "http://example.com/a%20%C2%A0test", + expectedURL: "example.com/a %C2%A0test", + copyExpected: "http://example.com/a%20%C2%A0test", + }, + { + loadURL: "http://example.com/%20%20%20", + expectedURL: "example.com/%20%20%20", + copyExpected: "http://example.com/%20%20%20", + }, + { + loadURL: "http://example.com/%E3%80%80%E3%80%80", + expectedURL: "example.com/%E3%80%80%E3%80%80", + copyExpected: "http://example.com/%E3%80%80%E3%80%80", + }, + + // Loading of javascript: URI results in previous URI, so if the previous + // entry changes, change this one too! + { + loadURL: "javascript:('%C3%A9%20%25%50')", + expectedLoad: "http://example.com/%E3%80%80%E3%80%80", + expectedURL: "example.com/%E3%80%80%E3%80%80", + copyExpected: "http://example.com/%E3%80%80%E3%80%80", + }, + + // data: URIs shouldn't be encoded + { + loadURL: "data:text/html,(%C3%A9%20%25%50)", + expectedURL: "data:text/html,(%C3%A9 %25P)", + copyExpected: "data:text/html,(%C3%A9 %25P)", + }, + { + copyVal: "<data:text/html,(>%C3%A9 %25P)", + copyExpected: "data:text/html,(", + }, + { + copyVal: "<data:text/html,(%C3%A9 %25P>)", + copyExpected: "data:text/html,(%C3%A9 %25P", + }, + + { + async setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.decodeURLsOnCopy", true]], + }); + }, + async cleanup() { + await SpecialPowers.popPrefEnv(); + }, + loadURL: + "http://example.com/%D0%B1%D0%B8%D0%BE%D0%B3%D1%80%D0%B0%D1%84%D0%B8%D1%8F", + expectedURL: "example.com/биография", + copyExpected: "http://example.com/биография", + }, + { + copyVal: "<example.com/би>ография", + copyExpected: "http://example.com/%D0%B1%D0%B8", + }, + + { + async setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.decodeURLsOnCopy", true]], + }); + // Setup a valid intranet url that resolves but is not yet known. + const proxyService = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" + ].getService(Ci.nsIProtocolProxyService); + let proxyInfo = proxyService.newProxyInfo( + "http", + "localhost", + 8888, + "", + "", + 0, + 4096, + null + ); + this._proxyFilter = { + applyFilter(channel, defaultProxyInfo, callback) { + callback.onProxyFilterResult( + channel.URI.host === "mytest" ? proxyInfo : defaultProxyInfo + ); + }, + }; + proxyService.registerChannelFilter(this._proxyFilter, 0); + registerCleanupFunction(() => { + if (this._proxyFilter) { + proxyService.unregisterChannelFilter(this._proxyFilter); + } + }); + }, + async cleanup() { + await SpecialPowers.popPrefEnv(); + const proxyService = Cc[ + "@mozilla.org/network/protocol-proxy-service;1" + ].getService(Ci.nsIProtocolProxyService); + proxyService.unregisterChannelFilter(this._proxyFilter); + this._proxyFilter = null; + }, + loadURL: "http://mytest/", + expectedURL: "mytest", + expectedValueOnFocus: "http://mytest/", + copyExpected: "http://mytest/", + }, + + { + async setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.decodeURLsOnCopy", true]], + }); + }, + async cleanup() { + await SpecialPowers.popPrefEnv(); + }, + loadURL: "https://example.com/", + expectedURL: "https://example.com", + copyExpected: "https://example.com", + }, +]; + +function testCopy(copyVal, targetValue) { + info("Expecting copy of: " + targetValue); + + if (copyVal) { + let offsets = []; + while (true) { + let startBracket = copyVal.indexOf("<"); + let endBracket = copyVal.indexOf(">"); + if (startBracket == -1 && endBracket == -1) { + break; + } + if (startBracket > endBracket || startBracket == -1) { + offsets = []; + break; + } + offsets.push([startBracket, endBracket - 1]); + copyVal = copyVal.replace("<", "").replace(">", ""); + } + if (!offsets.length || copyVal != gURLBar.value) { + ok(false, "invalid copyVal: " + copyVal); + } + gURLBar.selectionStart = offsets[0][0]; + gURLBar.selectionEnd = offsets[0][1]; + if (offsets.length > 1) { + let sel = gURLBar.editor.selection; + let r0 = sel.getRangeAt(0); + let node0 = r0.startContainer; + sel.removeAllRanges(); + offsets.map(function (startEnd) { + let range = r0.cloneRange(); + range.setStart(node0, startEnd[0]); + range.setEnd(node0, startEnd[1]); + sel.addRange(range); + }); + } + } else { + gURLBar.select(); + } + + return SimpleTest.promiseClipboardChange(targetValue, () => + goDoCommand("cmd_copy") + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_customizeMode.js b/browser/components/urlbar/tests/browser/browser_customizeMode.js new file mode 100644 index 0000000000..0ed26644cc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_customizeMode.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks that the left/right arrow keys and home/end keys work in +// the urlbar after customize mode starts and ends. + +"use strict"; + +add_task(async function test() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await startCustomizing(win); + await endCustomizing(win); + + let urlbar = win.gURLBar; + + let value = "example"; + urlbar.value = value; + urlbar.focus(); + urlbar.selectionEnd = value.length; + urlbar.selectionStart = value.length; + + // left + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + Assert.equal(urlbar.selectionStart, value.length - 1); + Assert.equal(urlbar.selectionEnd, value.length - 1); + + // home + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowLeft", { metaKey: true }, win); + } else { + EventUtils.synthesizeKey("KEY_Home", {}, win); + } + Assert.equal(urlbar.selectionStart, 0); + Assert.equal(urlbar.selectionEnd, 0); + + // right + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + Assert.equal(urlbar.selectionStart, 1); + Assert.equal(urlbar.selectionEnd, 1); + + // end + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowRight", { metaKey: true }, win); + } else { + EventUtils.synthesizeKey("KEY_End", {}, win); + } + Assert.equal(urlbar.selectionStart, value.length); + Assert.equal(urlbar.selectionEnd, value.length); + + await BrowserTestUtils.closeWindow(win); +}); + +async function startCustomizing(win = window) { + if (win.document.documentElement.getAttribute("customizing") != "true") { + let eventPromise = BrowserTestUtils.waitForEvent( + win.gNavToolbox, + "customizationready" + ); + win.gCustomizeMode.enter(); + await eventPromise; + } +} + +async function endCustomizing(win = window) { + if (win.document.documentElement.getAttribute("customizing") == "true") { + let eventPromise = BrowserTestUtils.waitForEvent( + win.gNavToolbox, + "aftercustomization" + ); + win.gCustomizeMode.exit(); + await eventPromise; + } +} diff --git a/browser/components/urlbar/tests/browser/browser_cutting.js b/browser/components/urlbar/tests/browser/browser_cutting.js new file mode 100644 index 0000000000..31b51751d9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_cutting.js @@ -0,0 +1,17 @@ +/* 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/. */ + +function test() { + gURLBar.focus(); + gURLBar.inputField.value = "https://example.com/"; + gURLBar.selectionStart = 4; + gURLBar.selectionEnd = 5; + goDoCommand("cmd_cut"); + is( + gURLBar.inputField.value, + "http://example.com/", + "location bar value after cutting 's' from https" + ); + gURLBar.handleRevert(); +} diff --git a/browser/components/urlbar/tests/browser/browser_decode.js b/browser/components/urlbar/tests/browser/browser_decode.js new file mode 100644 index 0000000000..ae0b4dfda1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_decode.js @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test makes sure (1) you can't break the urlbar by typing particular JSON +// or JS fragments into it, (2) urlbar.textValue shows URLs unescaped, and (3) +// the urlbar also shows the URLs embedded in action URIs unescaped. See bug +// 1233672. + +add_task(async function injectJSON() { + let inputStrs = [ + 'http://example.com/ ", "url": "bar', + "http://example.com/\\", + 'http://example.com/"', + 'http://example.com/","url":"evil.com', + "http://mozilla.org/\\u0020", + 'http://www.mozilla.org/","url":1e6,"some-key":"foo', + 'http://www.mozilla.org/","url":null,"some-key":"foo', + 'http://www.mozilla.org/","url":["foo","bar"],"some-key":"foo', + ]; + for (let inputStr of inputStrs) { + await checkInput(inputStr); + } + gURLBar.value = ""; + gURLBar.handleRevert(); + gURLBar.blur(); +}); + +add_task(function losslessDecode() { + let urlNoScheme = "example.com/\u30a2\u30a4\u30a6\u30a8\u30aa"; + let url = "http://" + urlNoScheme; + const result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url } + ); + gURLBar.setValueFromResult({ result }); + // Since this is directly setting textValue, it is expected to be trimmed. + Assert.equal( + gURLBar.inputField.value, + urlNoScheme, + "The string displayed in the textbox should not be escaped" + ); + gURLBar.value = ""; + gURLBar.handleRevert(); + gURLBar.blur(); +}); + +add_task(async function actionURILosslessDecode() { + let urlNoScheme = "example.com/\u30a2\u30a4\u30a6\u30a8\u30aa"; + let url = "http://" + urlNoScheme; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: url, + }); + + // At this point the heuristic result is selected but the urlbar's value is + // simply `url`. Key down and back around until the heuristic result is + // selected again. + do { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } while (UrlbarTestUtils.getSelectedRowIndex(window) != 0); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Should have selected a result of URL type" + ); + + Assert.equal( + gURLBar.inputField.value, + urlNoScheme, + "The string displayed in the textbox should not be escaped" + ); + + gURLBar.value = ""; + gURLBar.handleRevert(); + gURLBar.blur(); +}); + +add_task(async function test_resultsDisplayDecoded() { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + + await PlacesTestUtils.addVisits("http://example.com/%E9%A1%B5"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.displayed.url, + "http://example.com/\u9875", + "Should be displayed the correctly unescaped URL" + ); +}); + +async function checkInput(inputStr) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: inputStr, + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + // URL matches have their param.urls fixed up. + let fixupInfo = Services.uriFixup.getFixupURIInfo( + inputStr, + Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS | + Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP + ); + let expectedVisitURL = fixupInfo.fixedURI.spec; + + Assert.equal(result.url, expectedVisitURL, "Should have the correct URL"); + Assert.equal( + result.title, + inputStr.replace("\\", "/"), + "Should have the correct title" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Should have be a result of type URL" + ); + + Assert.equal( + result.displayed.title, + inputStr.replace("\\", "/"), + "Should be displaying the correct text" + ); + let [action] = await document.l10n.formatValues([ + { id: "urlbar-result-action-visit" }, + ]); + Assert.equal( + result.displayed.action, + action, + "Should be displaying the correct action text" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_delete.js b/browser/components/urlbar/tests/browser/browser_delete.js new file mode 100644 index 0000000000..f4a883ea30 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_delete.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test deleting the start of urls works correctly. + */ + +add_task(async function () { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://bug1105244.example.com/", + title: "test", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + await BrowserTestUtils.withNewTab("about:blank", testDelete); +}); + +function sendHome() { + // unclear why VK_HOME doesn't work on Mac, but it doesn't... + if (AppConstants.platform == "macosx") { + EventUtils.synthesizeKey("KEY_ArrowLeft", { metaKey: true }); + } else { + EventUtils.synthesizeKey("KEY_Home"); + } +} + +function sendDelete() { + EventUtils.synthesizeKey("KEY_Delete"); +} + +async function testDelete() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bug1105244", + }); + + // move to the start. + sendHome(); + + // delete the first few chars - each delete should operate on the input field. + await UrlbarTestUtils.promisePopupOpen(window, sendDelete); + Assert.equal(gURLBar.inputField.value, "ug1105244.example.com/"); + sendDelete(); + Assert.equal(gURLBar.inputField.value, "g1105244.example.com/"); +} diff --git a/browser/components/urlbar/tests/browser/browser_deleteAllText.js b/browser/components/urlbar/tests/browser/browser_deleteAllText.js new file mode 100644 index 0000000000..5b355fa477 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_deleteAllText.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that deleting all text in the input doesn't mess up +// subsequent searches. + +"use strict"; + +add_task(async function test() { + await runTest(); + // Setting suggest.topsites to false disables the view's autoOpen behavior, + // which changes this test's outcomes. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.topsites", false]], + }); + info("Running the test with autoOpen disabled."); + await runTest(); + await SpecialPowers.popPrefEnv(); +}); + +async function runTest() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://mozilla.org/", + ]); + + // Do an initial search for "x". + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "x", + fireInputEvent: true, + }); + await checkResults(); + + await deleteInput(); + + // Type "x". A new search should start. Don't use + // promiseAutocompleteResultPopup, which has some logic that starts the search + // manually in certain conditions. We want to specifically check that the + // input event causes UrlbarInput to start a new search on its own. If it + // doesn't, then the test will hang here on promiseSearchComplete. + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + await checkResults(); + + // Now repeat the backspace + x two more times. Same thing should happen. + for (let i = 0; i < 2; i++) { + await deleteInput(); + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + await checkResults(); + } + + await deleteInput(); + // autoOpen opened the panel, so we need to close it. + gURLBar.view.close(); +} + +async function checkResults() { + Assert.equal(await UrlbarTestUtils.getResultCount(window), 2); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(details.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(details.searchParams.query, "x"); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(details.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal(details.url, "http://example.com/"); +} + +async function deleteInput() { + if (UrlbarPrefs.get("suggest.topsites")) { + // The popup should remain open and show top sites. + while (gURLBar.value.length) { + EventUtils.synthesizeKey("KEY_Backspace"); + } + Assert.ok( + gURLBar.view.isOpen, + "View should remain open when deleting all input text" + ); + let queryContext = await UrlbarTestUtils.promiseSearchComplete(window); + Assert.notEqual( + queryContext.results.length, + 0, + "View should show results when deleting all input text" + ); + Assert.equal( + queryContext.searchString, + "", + "Results should be for the empty search string (i.e. top sites) when deleting all input text" + ); + } else { + // Deleting all text should close the view. + await UrlbarTestUtils.promisePopupClose(window, () => { + while (gURLBar.value.length) { + EventUtils.synthesizeKey("KEY_Backspace"); + } + }); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_display_selectedAction_Extensions.js b/browser/components/urlbar/tests/browser/browser_display_selectedAction_Extensions.js new file mode 100644 index 0000000000..d3a51ede76 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_display_selectedAction_Extensions.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for the presence of selected action text "Extensions:" in the URL bar. + */ + +add_task(async function testSwitchToTabTextDisplay() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + // Just do nothing for this test. + }, + }, + }); + + await extension.startup(); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "omniboxtest ", + fireInputEvent: true, + }); + + // The "Extension:" label appears after a key down followed by a key up + // back to the extension result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Checks to see if "Extension:" text in URL bar is visible + const extensionText = document.getElementById("urlbar-label-extension"); + Assert.ok(BrowserTestUtils.is_visible(extensionText)); + Assert.equal(extensionText.value, "Extension:"); + + // Check to see if all other labels are hidden + const allLabels = document.getElementById("urlbar-label-box").children; + for (let label of allLabels) { + if (label != extensionText) { + Assert.ok(BrowserTestUtils.is_hidden(label)); + } + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + await extension.unload(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_dns_first_for_single_words.js b/browser/components/urlbar/tests/browser/browser_dns_first_for_single_words.js new file mode 100644 index 0000000000..a0aacc83d2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dns_first_for_single_words.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that if browser.fixup.dns_first_for_single_words pref is set, we pass +// the original search string to the docshell and not a search url. + +add_task(async function test() { + const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" + ); + const sandbox = sinon.createSandbox(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.fixup.dns_first_for_single_words", true]], + }); + + registerCleanupFunction(sandbox.restore); + + /** + * Tests the given search string. + * + * @param {string} str The search string + * @param {boolean} passthrough whether the value should be passed unchanged + * to the docshell that will first execute a DNS request. + */ + async function testVal(str, passthrough) { + sandbox.stub(gURLBar, "_loadURL").callsFake(url => { + if (passthrough) { + Assert.equal(url, str, "Should pass the unmodified search string"); + } else { + Assert.ok(url.startsWith("http"), "Should pass an url"); + } + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: str, + }); + EventUtils.synthesizeKey("KEY_Enter"); + sandbox.restore(); + } + + await testVal("test", true); + await testVal("te-st", true); + await testVal("test ", true); + await testVal(" test", true); + await testVal(" test", true); + await testVal("test.test", true); + await testVal("test test", false); + // This is not a single word host, though it contains one. At a certain point + // we may evaluate to increase coverage of the feature to also ask for this. + await testVal("test/test", false); +}); diff --git a/browser/components/urlbar/tests/browser/browser_downArrowKeySearch.js b/browser/components/urlbar/tests/browser/browser_downArrowKeySearch.js new file mode 100644 index 0000000000..2f9cc19983 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_downArrowKeySearch.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that pressing the down arrow key starts the proper searches, depending +// on the input value/state. + +"use strict"; + +add_setup(async function () { + await PlacesUtils.history.clear(); + // Enough vists to get this site into Top Sites. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + await updateTopSites( + sites => sites && sites[0] && sites[0].url == "http://example.com/" + ); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function url() { + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + gURLBar.focus(); + gURLBar.selectionEnd = gURLBar.untrimmedValue.length; + gURLBar.selectionStart = gURLBar.untrimmedValue.length; + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(details.url, "http://example.com/"); + Assert.equal(gURLBar.value, "example.com/"); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +add_task(async function userTyping() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(details.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.ok(details.searchParams); + Assert.equal(details.searchParams.query, "foo"); + Assert.equal(gURLBar.value, "foo"); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function empty() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), -1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(details.url, "http://example.com/"); + Assert.equal(gURLBar.value, ""); +}); + +add_task(async function new_window() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + win.gURLBar.focus(); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(win), -1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal(details.url, "http://example.com/"); + Assert.equal(win.gURLBar.value, ""); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_dragdropURL.js b/browser/components/urlbar/tests/browser/browser_dragdropURL.js new file mode 100644 index 0000000000..52c19e8965 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dragdropURL.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for draging and dropping to the Urlbar. + */ + +const TEST_URL = "data:text/html,a test page"; + +add_task(async function test_setup() { + // Stop search-engine loads from hitting the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(async function cleanup() { + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + }); + + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); +}); + +/** + * Simulates a drop on the URL bar input field. + * The drag source must be something different from the URL bar, so we pick the + * home button somewhat arbitrarily. + * + * @param {object} content a {type, data} object representing the DND content. + */ +function simulateURLBarDrop(content) { + EventUtils.synthesizeDrop( + document.getElementById("home-button"), // Dragstart element. + gURLBar.inputField, // Drop element. + [[content]], // Drag data. + "copy", + window + ); +} + +add_task(async function checkDragURL() { + await BrowserTestUtils.withNewTab(TEST_URL, function (browser) { + info("Check dragging a normal url to the urlbar"); + const DRAG_URL = "http://www.example.com/"; + simulateURLBarDrop({ type: "text/plain", data: DRAG_URL }); + Assert.equal( + gURLBar.value, + TEST_URL, + "URL bar value should not have changed" + ); + Assert.equal( + gBrowser.selectedBrowser.userTypedValue, + null, + "Stored URL bar value should not have changed" + ); + }); +}); + +add_task(async function checkDragForbiddenURL() { + await BrowserTestUtils.withNewTab(TEST_URL, function (browser) { + // See also browser_removeUnsafeProtocolsFromURLBarPaste.js for other + // examples. In general we trust that function, we pick some testcases to + // ensure we disallow dropping trimmed text. + for (let url of [ + "chrome://browser/content/aboutDialog.xhtml", + "file:///", + "javascript:", + "javascript:void(0)", + "java\r\ns\ncript:void(0)", + " javascript:void(0)", + "\u00A0java\nscript:void(0)", + "javascript:document.domain", + "javascript:javascript:alert('hi!')", + ]) { + info(`Check dragging "{$url}" to the URL bar`); + simulateURLBarDrop({ type: "text/plain", data: url }); + Assert.notEqual( + gURLBar.value, + url, + `Shouldn't be allowed to drop ${url} on URL bar` + ); + } + }); +}); + +add_task(async function checkDragText() { + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + info("Check dragging multi word text to the urlbar"); + const TEXT = "Firefox is awesome"; + const TEXT_URL = "https://example.com/?q=Firefox+is+awesome"; + let promiseLoad = BrowserTestUtils.browserLoaded(browser, false, TEXT_URL); + simulateURLBarDrop({ type: "text/plain", data: TEXT }); + await promiseLoad; + + info("Check dragging single word text to the urlbar"); + const WORD = "Firefox"; + const WORD_URL = "https://example.com/?q=Firefox"; + promiseLoad = BrowserTestUtils.browserLoaded(browser, false, WORD_URL); + simulateURLBarDrop({ type: "text/plain", data: WORD }); + await promiseLoad; + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_dynamicResults.js b/browser/components/urlbar/tests/browser/browser_dynamicResults.js new file mode 100644 index 0000000000..a4e9013be5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dynamicResults.js @@ -0,0 +1,799 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests dynamic results. + */ + +"use strict"; + +const DYNAMIC_TYPE_NAME = "test"; + +const DYNAMIC_TYPE_VIEW_TEMPLATE = { + stylesheet: getRootDirectory(gTestPath) + "dynamicResult0.css", + children: [ + { + name: "selectable", + tag: "span", + attributes: { + selectable: "true", + }, + }, + { + name: "text", + tag: "span", + }, + { + name: "buttonBox", + tag: "span", + children: [ + { + name: "button1", + tag: "span", + attributes: { + role: "button", + attribute_to_remove: "value", + }, + }, + { + name: "button2", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }, + ], +}; + +const DUMMY_PAGE = + "http://example.com/browser/browser/base/content/test/general/dummy_page.html"; + +// Tests the dynamic type registration functions and stylesheet loading. +add_task(async function registration() { + // Get our test stylesheet URIs. + let stylesheetURIs = []; + for (let i = 0; i < 2; i++) { + stylesheetURIs.push( + Services.io.newURI(getRootDirectory(gTestPath) + `dynamicResult${i}.css`) + ); + } + + // Maps from dynamic type names to their type. + let viewTemplatesByName = { + foo: { + stylesheet: stylesheetURIs[0].spec, + children: [ + { + name: "text", + tag: "span", + }, + ], + }, + bar: { + stylesheet: stylesheetURIs[1].spec, + children: [ + { + name: "icon", + tag: "span", + }, + { + name: "button", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }, + }; + + // First, open another window so that multiple windows are open when we add + // the types so we can verify below that the stylesheets are added to all open + // windows. + let newWindows = []; + newWindows.push(await BrowserTestUtils.openNewBrowserWindow()); + + // Add the test dynamic types. + for (let [name, viewTemplate] of Object.entries(viewTemplatesByName)) { + UrlbarResult.addDynamicResultType(name); + UrlbarView.addDynamicViewTemplate(name, viewTemplate); + } + + // Get them back to make sure they were added. + for (let name of Object.keys(viewTemplatesByName)) { + let actualType = UrlbarResult.getDynamicResultType(name); + // Types are currently just empty objects. + Assert.deepEqual(actualType, {}, "Types should match"); + } + + // Their stylesheets should have been applied to all open windows. There's no + // good way to check this because: + // + // * nsIStyleSheetService has a function that returns whether a stylesheet has + // been loaded, but it's global and not per window. + // * nsIDOMWindowUtils has functions to load stylesheets but not one to check + // whether a stylesheet has been loaded. + // * document.stylesheets only contains stylesheets in the DOM. + // + // So instead we set a CSS variable on #urlbar in each of our stylesheets and + // check that it's present. + function getCSSVariables(windows) { + let valuesByWindow = new Map(); + for (let win of windows) { + let values = []; + valuesByWindow.set(window, values); + for (let i = 0; i < stylesheetURIs.length; i++) { + let value = win + .getComputedStyle(gURLBar.panel) + .getPropertyValue(`--testDynamicResult${i}`); + values.push((value || "").trim()); + } + } + return valuesByWindow; + } + function checkCSSVariables(windows) { + for (let values of getCSSVariables(windows).values()) { + for (let i = 0; i < stylesheetURIs.length; i++) { + if (values[i].trim() !== `ok${i}`) { + return false; + } + } + } + return true; + } + + // The stylesheets are loaded asyncly, so we need to poll for it. + await TestUtils.waitForCondition(() => + checkCSSVariables(BrowserWindowTracker.orderedWindows) + ); + Assert.ok(true, "Stylesheets loaded in all open windows"); + + // Open another window to make sure the stylesheets are loaded in it after we + // added the new dynamic types. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + newWindows.push(newWin); + await TestUtils.waitForCondition(() => checkCSSVariables([newWin])); + Assert.ok(true, "Stylesheets loaded in new window"); + + // Remove the dynamic types. + for (let name of Object.keys(viewTemplatesByName)) { + UrlbarView.removeDynamicViewTemplate(name); + UrlbarResult.removeDynamicResultType(name); + let actualType = UrlbarResult.getDynamicResultType(name); + Assert.equal(actualType, null, "Type should be unregistered"); + } + + // The stylesheets should be removed from all windows. + let valuesByWindow = getCSSVariables(BrowserWindowTracker.orderedWindows); + for (let values of valuesByWindow.values()) { + for (let i = 0; i < stylesheetURIs.length; i++) { + Assert.ok(!values[i], "Stylesheet should be removed"); + } + } + + // Close the new windows. + for (let win of newWindows) { + await BrowserTestUtils.closeWindow(win); + } +}); + +// Tests that the view is created correctly from the view template. +add_task(async function viewCreated() { + await withDynamicTypeProvider(async () => { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + // Get the row. + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + Assert.equal( + row.getAttribute("dynamicType"), + DYNAMIC_TYPE_NAME, + "row[dynamicType]" + ); + let inner = row.querySelector(".urlbarView-row-inner"); + Assert.ok(inner, ".urlbarView-row-inner should exist"); + + // Check the DOM. + checkDOM(inner, DYNAMIC_TYPE_VIEW_TEMPLATE.children); + + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Tests that the view is updated correctly. +async function checkViewUpdated(provider) { + await withDynamicTypeProvider(async () => { + // Test a few different search strings. The dynamic result view will be + // updated to reflect the current string. + for (let searchString of ["test", "some other string", "and another"]) { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + waitForFocus: SimpleTest.waitForFocus, + }); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let text = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text` + ); + + // The view's call to provider.getViewUpdate is async, so we need to make + // sure the update has been applied before continuing to avoid + // intermittent failures. + await TestUtils.waitForCondition( + () => text.getAttribute("searchString") == searchString + ); + + // The "searchString" attribute of these elements should be updated. + let elementNames = ["selectable", "text", "button1", "button2"]; + for (let name of elementNames) { + let element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}` + ); + Assert.equal( + element.getAttribute("searchString"), + searchString, + 'element.getAttribute("searchString")' + ); + } + + let button1 = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-button1` + ); + + Assert.equal( + button1.hasAttribute("attribute_to_remove"), + false, + "Attribute should be removed" + ); + + // text.textContent should be updated. + Assert.equal( + text.textContent, + `result.payload.searchString is: ${searchString}`, + "text.textContent" + ); + + await UrlbarTestUtils.promisePopupClose(window); + } + }, provider); +} + +add_task(async function checkViewUpdatedPlain() { + await checkViewUpdated(new TestProvider()); +}); + +add_task(async function checkViewUpdatedWDynamicViewTemplate() { + /** + * A dummy provider that provides the viewTemplate dynamically. + */ + class TestShouldCallGetViewTemplateProvider extends TestProvider { + getViewTemplateWasCalled = false; + + getViewTemplate() { + this.getViewTemplateWasCalled = true; + return DYNAMIC_TYPE_VIEW_TEMPLATE; + } + } + + let provider = new TestShouldCallGetViewTemplateProvider(); + Assert.ok( + !provider.getViewTemplateWasCalled, + "getViewTemplate has not yet been called for the provider" + ); + Assert.ok( + !UrlbarView.dynamicViewTemplatesByName.get(DYNAMIC_TYPE_NAME), + "No template has been registered" + ); + await checkViewUpdated(provider); + Assert.ok( + provider.getViewTemplateWasCalled, + "getViewTemplate was called for the provider" + ); +}); + +// Tests that selection correctly moves through buttons and selectables in a +// dynamic result. +add_task(async function selection() { + await withDynamicTypeProvider(async () => { + // Add a visit so we have at least one result after the dynamic result. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits("http://example.com/test"); + + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + // Sanity check that the dynamic result is at index 1. + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + + // The heuristic result will be selected. TAB from the heuristic through + // all the selectable elements in the dynamic result. + let selectables = ["selectable", "button1", "button2"]; + for (let name of selectables) { + let element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}` + ); + Assert.ok(element, "Sanity check element"); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + element, + `Selected element: ${name}` + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Row at index 1 selected" + ); + Assert.equal(UrlbarTestUtils.getSelectedRow(window), row, "Row selected"); + } + + // TAB again to select the result after the dynamic result. + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 2, + "Row at index 2 selected" + ); + Assert.notEqual( + UrlbarTestUtils.getSelectedRow(window), + row, + "Row is not selected" + ); + + // SHIFT+TAB back through the dynamic result. + for (let name of selectables.reverse()) { + let element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${name}` + ); + Assert.ok(element, "Sanity check element"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + element, + `Selected element: ${name}` + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Row at index 1 selected" + ); + Assert.equal(UrlbarTestUtils.getSelectedRow(window), row, "Row selected"); + } + + // SHIFT+TAB again to select the heuristic result. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "Row at index 0 selected" + ); + Assert.notEqual( + UrlbarTestUtils.getSelectedRow(window), + row, + "Row is not selected" + ); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); + }); +}); + +// Tests picking elements in a dynamic result. +add_task(async function pick() { + await withDynamicTypeProvider(async provider => { + let selectables = ["selectable", "button1", "button2"]; + for (let i = 0; i < selectables.length; i++) { + let selectable = selectables[i]; + + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + // Sanity check that the dynamic result is at index 1. + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + + // The heuristic result will be selected. TAB from the heuristic + // to the selectable element. + let element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${selectable}` + ); + Assert.ok(element, "Sanity check element"); + EventUtils.synthesizeKey("KEY_Tab", { repeat: i + 1 }); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + element, + `Selected element: ${name}` + ); + + // Pick the element. + let pickPromise = provider.promisePick(); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + let [result, pickedElement] = await pickPromise; + Assert.equal(result, row.result, "Picked result"); + Assert.equal(pickedElement, element, "Picked element"); + } + }); +}); + +// Tests picking elements in a dynamic result. +add_task(async function shouldNavigate() { + /** + * A dummy provider that providers results with a `shouldNavigate` property. + */ + class TestShouldNavigateProvider extends TestProvider { + /** + * @param {object} context - Data regarding the context of the query. + * @param {Function} addCallback - Function to add a result to the query. + */ + async startQuery(context, addCallback) { + for (let result of this._results) { + result.payload.searchString = context.searchString; + result.payload.shouldNavigate = true; + result.payload.url = DUMMY_PAGE; + addCallback(this, result); + } + } + } + + await withDynamicTypeProvider(async provider => { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + // Sanity check that the dynamic result is at index 1. + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + + // The heuristic result will be selected. TAB from the heuristic + // to the selectable element. + let element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-selectable` + ); + Assert.ok(element, "Sanity check element"); + EventUtils.synthesizeKey("KEY_Tab", { repeat: 1 }); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + element, + `Selected element: ${name}` + ); + + // Pick the element. + let pickPromise = provider.promisePick(); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + // Verify that onEngagement was still called. + let [result, pickedElement] = await pickPromise; + Assert.equal(result, row.result, "Picked result"); + Assert.equal(pickedElement, element, "Picked element"); + + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gBrowser.currentURI.spec, + DUMMY_PAGE, + "We navigated to payload.url when result selected" + ); + + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:home"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:home" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + element = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-selectable` + ); + + pickPromise = provider.promisePick(); + EventUtils.synthesizeMouseAtCenter(element, {}); + [result, pickedElement] = await pickPromise; + Assert.equal(result, row.result, "Picked result"); + Assert.equal(pickedElement, element, "Picked element"); + + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gBrowser.currentURI.spec, + DUMMY_PAGE, + "We navigated to payload.url when result is clicked" + ); + }, new TestShouldNavigateProvider()); +}); + +// Tests applying highlighting to a dynamic result. +add_task(async function highlighting() { + /** + * Provides a dynamic result with highlighted text. + */ + class TestHighlightProvider extends TestProvider { + startQuery(context, addCallback) { + let result = Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...UrlbarResult.payloadAndSimpleHighlights(context.tokens, { + dynamicType: DYNAMIC_TYPE_NAME, + text: ["Test title", UrlbarUtils.HIGHLIGHT.SUGGESTED], + }) + ), + { suggestedIndex: 1 } + ); + addCallback(this, result); + } + + getViewUpdate(result, idsByName) { + return {}; + } + } + + // Test that highlighting is applied. + await withDynamicTypeProvider(async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + let parentTextNode = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text` + ); + let highlightedTextNode = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text > strong` + ); + Assert.equal(parentTextNode.firstChild.textContent, "Test"); + Assert.equal( + highlightedTextNode.textContent, + " title", + "The highlighting was applied successfully." + ); + }, new TestHighlightProvider()); + + /** + * Provides a dynamic result with highlighted text that is then overridden. + */ + class TestHighlightProviderOveridden extends TestHighlightProvider { + getViewUpdate(result, idsByName) { + return { + text: { + textContent: "Test title", + }, + }; + } + } + + // Test that highlighting is not applied when overridden from getViewUpdate. + await withDynamicTypeProvider(async () => { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + let parentTextNode = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text` + ); + let highlightedTextNode = row.querySelector( + `.urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-text > strong` + ); + Assert.equal( + parentTextNode.firstChild.textContent, + "Test title", + "No highlighting was applied" + ); + Assert.ok(!highlightedTextNode, "The <strong> child node was deleted."); + }, new TestHighlightProviderOveridden()); +}); + +/** + * Provides a dynamic result. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: DYNAMIC_TYPE_NAME, + } + ), + { suggestedIndex: 1 } + ), + ], + }); + } + + async startQuery(context, addCallback) { + for (let result of this._results) { + result.payload.searchString = context.searchString; + addCallback(this, result); + } + } + + getViewUpdate(result, idsByName) { + for (let child of DYNAMIC_TYPE_VIEW_TEMPLATE.children) { + Assert.ok(idsByName.get(child.name), `idsByName contains ${child.name}`); + } + + return { + selectable: { + textContent: "Selectable", + attributes: { + searchString: result.payload.searchString, + }, + }, + text: { + textContent: `result.payload.searchString is: ${result.payload.searchString}`, + attributes: { + searchString: result.payload.searchString, + }, + }, + button1: { + textContent: "Button 1", + attributes: { + searchString: result.payload.searchString, + attribute_to_remove: null, + }, + }, + button2: { + textContent: "Button 2", + attributes: { + searchString: result.payload.searchString, + }, + }, + }; + } + + onEngagement(isPrivate, state, queryContext, details) { + if (this._pickPromiseResolve) { + let { result, element } = details; + this._pickPromiseResolve([result, element]); + delete this._pickPromiseResolve; + delete this._pickPromise; + } + } + + promisePick() { + this._pickPromise = new Promise(resolve => { + this._pickPromiseResolve = resolve; + }); + return this._pickPromise; + } +} + +/** + * Provides a dynamic result. + * + * @param {object} callback - Function that runs the body of the test. + * @param {object} provider - The dummy provider to use. + */ +async function withDynamicTypeProvider( + callback, + provider = new TestProvider() +) { + // Add a dynamic result type. + UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME); + if (!provider.getViewTemplate) { + UrlbarView.addDynamicViewTemplate( + DYNAMIC_TYPE_NAME, + DYNAMIC_TYPE_VIEW_TEMPLATE + ); + } + + // Add a provider of the dynamic type. + UrlbarProvidersManager.registerProvider(provider); + + await callback(provider); + + // Clean up. + UrlbarProvidersManager.unregisterProvider(provider); + if (!provider.getViewTemplate) { + UrlbarView.removeDynamicViewTemplate(DYNAMIC_TYPE_NAME); + } + UrlbarResult.removeDynamicResultType(DYNAMIC_TYPE_NAME); +} + +function checkDOM(parentNode, expectedChildren) { + info( + `checkDOM: Checking parentNode id=${parentNode.id} className=${parentNode.className}` + ); + for (let i = 0; i < expectedChildren.length; i++) { + let child = expectedChildren[i]; + let actualChild = parentNode.children[i]; + info(`checkDOM: Checking expected child: ${JSON.stringify(child)}`); + Assert.ok(actualChild, "actualChild should exist"); + Assert.equal(actualChild.tagName, child.tag, "child.tag"); + Assert.equal(actualChild.getAttribute("name"), child.name, "child.name"); + Assert.ok( + actualChild.classList.contains( + `urlbarView-dynamic-${DYNAMIC_TYPE_NAME}-${child.name}` + ), + "child.name should be in classList" + ); + // We have to use startsWith/endsWith since the middle of the ID is a random + // number. + Assert.ok(actualChild.id.startsWith("urlbarView-row-")); + Assert.ok( + actualChild.id.endsWith(child.name), + "The child was assigned the correct ID." + ); + for (let [name, value] of Object.entries(child.attributes || {})) { + if (name == "attribute_to_remove") { + Assert.equal( + actualChild.hasAttribute(name), + false, + `attribute: ${name}` + ); + continue; + } + Assert.equal(actualChild.getAttribute(name), value, `attribute: ${name}`); + } + for (let name of child.classList || []) { + Assert.ok(actualChild.classList.contains(name), `classList: ${name}`); + } + if (child.children) { + checkDOM(actualChild, child.children); + } + } +} diff --git a/browser/components/urlbar/tests/browser/browser_edit_invalid_url.js b/browser/components/urlbar/tests/browser/browser_edit_invalid_url.js new file mode 100644 index 0000000000..5a710c1285 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_edit_invalid_url.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that we trim invalid urls when they are selected, so that if the user +// modifies the selected url, or just closes the results pane, we do a visit +// rather than searching for the trimmed string. + +const url = BrowserUIUtils.trimURLProtocol + "invalid.somehost/mytest"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.trimURLs", true]], + }); + await PlacesTestUtils.addVisits(url); + registerCleanupFunction(PlacesUtils.history.clear); +}); + +add_task(async function test_escape() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "invalid", + }); + // Look for our result. + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.greater(resultCount, 1, "There should be at least two results"); + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + info(`Result at ${i} has url ${result.url}`); + if (result.url.startsWith(url)) { + break; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.equal( + gURLBar.value, + url, + "The string displayed in the textbox should be the untrimmed url" + ); + // Close the results pane by ESC. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + // Confirm the result and check the loaded page. + let promise = waitforLoadURL(); + EventUtils.synthesizeKey("KEY_Enter"); + let loadedUrl = await promise; + Assert.equal(loadedUrl, url, "Should try to load a url"); +}); + +add_task(async function test_edit_url() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "invalid", + }); + // Look for our result. + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.greater(resultCount, 1, "There should be at least two results"); + for (let i = 1; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + info(`Result at ${i} has url ${result.url}`); + if (result.url.startsWith(url)) { + break; + } + } + Assert.equal( + gURLBar.value, + url, + "The string displayed in the textbox should be the untrimmed url" + ); + // Modify the url. + EventUtils.synthesizeKey("2"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL, "Should visit a url"); + Assert.equal(result.url, url + "2", "Should visit the modified url"); + + // Confirm the result and check the loaded page. + let promise = waitforLoadURL(); + EventUtils.synthesizeKey("KEY_Enter"); + let loadedUrl = await promise; + Assert.equal(loadedUrl, url + "2", "Should try to load the modified url"); +}); + +async function waitforLoadURL() { + let sandbox = sinon.createSandbox(); + let loadedUrl = await new Promise(resolve => + sandbox.stub(gURLBar, "_loadURL").callsFake(resolve) + ); + sandbox.restore(); + return loadedUrl; +} diff --git a/browser/components/urlbar/tests/browser/browser_engagement.js b/browser/components/urlbar/tests/browser/browser_engagement.js new file mode 100644 index 0000000000..b964a61a75 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_engagement.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the UrlbarProvider.onEngagement() method. + +"use strict"; + +add_task(async function abandonment() { + await doTest({ + expectedEndState: "abandonment", + endEngagement: async () => { + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + }, + }); +}); + +add_task(async function engagement() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await doTest({ + expectedEndState: "engagement", + endEngagement: async () => { + let result, element; + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + result = gURLBar.view.selectedResult; + element = gURLBar.view.selectedElement; + EventUtils.synthesizeKey("KEY_Enter"); + }); + return { result, element }; + }, + expectedEndDetails: { + selIndex: 0, + selType: "history", + provider: "", + searchSource: "urlbar", + isSessionOngoing: false, + }, + }); + }); +}); + +add_task(async function privateWindow_abandonment() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + await doTest({ + win, + expectedEndState: "abandonment", + expectedIsPrivate: true, + endEngagement: async () => { + await UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()); + }, + }); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function privateWindow_engagement() { + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + await doTest({ + win, + expectedEndState: "engagement", + expectedIsPrivate: true, + endEngagement: async () => { + let result, element; + await UrlbarTestUtils.promisePopupClose(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + result = win.gURLBar.view.selectedResult; + element = win.gURLBar.view.selectedElement; + EventUtils.synthesizeKey("KEY_Enter", {}, win); + }); + return { result, element }; + }, + expectedEndDetails: { + selIndex: 0, + selType: "history", + provider: "", + searchSource: "urlbar", + isSessionOngoing: false, + }, + }); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Performs an engagement test. + * + * @param {object} options + * Options object. + * @param {string} options.expectedEndState + * The expected state at the end of the engagement. + * @param {Function} options.endEngagement + * A function that should end the engagement. If the expected end state is + * "engagement", the function should return `{ result, element }` with the + * expected engaged result and element. + * @param {window} [options.win] + * The window to perform the test in. + * @param {boolean} [options.expectedIsPrivate] + * Whether the engagement and query context are expected to be private. + * @param {object} [options.expectedEndDetails] + * The expected `details` at the end of the engagement. `searchString` is + * automatically included since it's always present. If `provider` is + * expected, then include it and set it to any value; this function will + * replace it with the name of the test provider. + */ +async function doTest({ + expectedEndState, + endEngagement, + win = window, + expectedIsPrivate = false, + expectedEndDetails = {}, +}) { + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + + let startPromise = provider.promiseEngagement(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "test", + fireInputEvent: true, + }); + + let [isPrivate, state, queryContext, details] = await startPromise; + Assert.equal(isPrivate, expectedIsPrivate, "Start isPrivate"); + Assert.equal(state, "start", "Start state"); + + // `queryContext` isn't always defined for `start`, and `onEngagement` + // shouldn't rely on it being defined on start, but there's no good reason to + // assert that it's not defined here. + + // Similarly, `details` is never defined for `start`, but there's no good + // reason to assert that it's not defined. + + let endPromise = provider.promiseEngagement(); + let { result, element } = (await endEngagement()) ?? {}; + + [isPrivate, state, queryContext, details] = await endPromise; + Assert.equal(isPrivate, expectedIsPrivate, "End isPrivate"); + Assert.equal(state, expectedEndState, "End state"); + Assert.ok(queryContext, "End queryContext"); + Assert.equal( + queryContext.isPrivate, + expectedIsPrivate, + "End queryContext.isPrivate" + ); + + let detailsDefaults = { + searchString: "test", + searchSource: "urlbar", + provider: undefined, + selIndex: -1, + }; + if ("provider" in expectedEndDetails) { + detailsDefaults.provider = provider.name; + delete expectedEndDetails.provider; + } + + if (expectedEndState == "engagement") { + Assert.ok( + result, + "endEngagement() should have returned the expected engaged result" + ); + Assert.ok( + element, + "endEngagement() should have returned the expected engaged element" + ); + expectedEndDetails.result = result; + expectedEndDetails.element = element; + } + + Assert.deepEqual( + details, + Object.assign(detailsDefaults, expectedEndDetails), + "End details" + ); + + UrlbarProvidersManager.unregisterProvider(provider); +} + +/** + * Test provider that resolves promises when onEngagement is called. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + _resolves = []; + + constructor() { + super({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/" } + ), + ], + }); + } + + onEngagement(...args) { + let resolve = this._resolves.shift(); + if (resolve) { + resolve(args); + } + } + + promiseEngagement() { + return new Promise(resolve => this._resolves.push(resolve)); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_enter.js b/browser/components/urlbar/tests/browser/browser_enter.js new file mode 100644 index 0000000000..de1cda7cc1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_enter.js @@ -0,0 +1,331 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_VALUE = "example.com/\xF7?\xF7"; +const START_VALUE = "example.com/%C3%B7?%C3%B7"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + const engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + engine.alias = "@default"; +}); + +add_task(async function returnKeypress() { + info("Simple return keypress"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, START_VALUE); + + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + // Check url bar and selected tab. + is( + gURLBar.value, + TEST_VALUE, + "Urlbar should preserve the value on return keypress" + ); + is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab"); + + // Cleanup. + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function altReturnKeypress() { + info("Alt+Return keypress"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, START_VALUE); + + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + + // wait for the new tab to appear. + await tabOpenPromise; + + // Check url bar and selected tab. + is( + gURLBar.value, + TEST_VALUE, + "Urlbar should preserve the value on return keypress" + ); + isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function altGrReturnKeypress() { + info("AltGr+Return keypress"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, START_VALUE); + + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter", { altGraphKey: true }); + + // wait for the new tab to appear. + await tabOpenPromise; + + // Check url bar and selected tab. + is( + gURLBar.value, + TEST_VALUE, + "Urlbar should preserve the value on return keypress" + ); + isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); + +add_task(async function searchOnEnterNoPick() { + info("Search on Enter without picking a urlbar result"); + await SpecialPowers.pushPrefEnv({ + // The test checks that the untrimmed value is equal to the spec. + // When using showSearchTerms, the untrimmed value becomes + // the search terms. + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + + // Why is BrowserTestUtils.openNewForegroundTab not causing the bug? + let promiseTabOpened = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeMouseAtCenter(gBrowser.tabContainer.newTabButton, {}); + let openEvent = await promiseTabOpened; + let tab = openEvent.target; + + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + null, + true + ); + gURLBar.focus(); + gURLBar.value = "test test"; + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + Assert.ok( + gBrowser.selectedBrowser.currentURI.spec.endsWith("test+test"), + "Should have loaded the correct page" + ); + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + gURLBar.untrimmedValue, + "The location should have changed" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function searchOnEnterSoon() { + info("Search on Enter as soon as typing a char"); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + START_VALUE + ); + + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + const onPageHide = SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return new Promise(resolve => { + content.window.addEventListener("pagehide", () => { + resolve(); + }); + }); + }); + const onResult = SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return new Promise(resolve => { + content.window.addEventListener("keyup", () => { + resolve("keyup"); + }); + content.window.addEventListener("unload", () => { + resolve("unload"); + }); + }); + }); + + // Focus on the input field in urlbar. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + const ownerDocument = gBrowser.selectedBrowser.ownerDocument; + is( + ownerDocument.activeElement, + gURLBar.inputField, + "The input field in urlbar has focus" + ); + + info("Keydown a char and Enter"); + EventUtils.synthesizeKey("x", { type: "keydown" }); + EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }); + + // Wait for pagehide event in the content. + await onPageHide; + is( + ownerDocument.activeElement, + gURLBar.inputField, + "The input field in urlbar still has focus" + ); + + // Check the caret position. + Assert.equal( + gURLBar.selectionStart, + gURLBar.value.length, + "The selectionStart indicates at ending of the value" + ); + Assert.equal( + gURLBar.selectionEnd, + gURLBar.value.length, + "The selectionEnd indicates at ending of the value" + ); + + // Keyup both key as soon as pagehide event happens. + EventUtils.synthesizeKey("x", { type: "keyup" }); + EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }); + + // Wait for moving the focus. + await TestUtils.waitForCondition( + () => ownerDocument.activeElement === gBrowser.selectedBrowser + ); + info("The focus is moved to the browser"); + + // Check whether keyup event is not captured before unload event happens. + const result = await onResult; + is(result, "unload", "Keyup event is not captured."); + + // Check the caret position again. + Assert.equal( + gURLBar.selectionStart, + 0, + "The selectionStart indicates at beginning of the value" + ); + Assert.equal( + gURLBar.selectionEnd, + 0, + "The selectionEnd indicates at beginning of the value" + ); + + // Cleanup. + await onLoad; + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function searchByMultipleEnters() { + info("Search on Enter after selecting the search engine by Enter"); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + START_VALUE + ); + + info("Select a search engine by Enter key"); + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendString("@default"); + EventUtils.synthesizeKey("KEY_Enter"); + await TestUtils.waitForCondition( + () => gURLBar.searchMode, + "Wait until entering search mode" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "browser_searchSuggestionEngine searchSuggestionEngine.xml", + entry: "keywordoffer", + }); + const ownerDocument = gBrowser.selectedBrowser.ownerDocument; + is( + ownerDocument.activeElement, + gURLBar.inputField, + "The input field in urlbar has focus" + ); + + info("Search by Enter key"); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.sendString("mozilla"); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + is( + ownerDocument.activeElement, + gBrowser.selectedBrowser, + "The focus is moved to the browser" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function typeCharWhileProcessingEnter() { + info("Typing a char while processing enter key"); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + START_VALUE + ); + + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + `http://${START_VALUE}` + ); + gURLBar.focus(); + + info("Keydown Enter"); + EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }); + await TestUtils.waitForCondition( + () => gURLBar._keyDownEnterDeferred, + "Wait for starting process for the enter key" + ); + + info("Keydown a char"); + EventUtils.synthesizeKey("x", { type: "keydown" }); + + info("Keyup both"); + EventUtils.synthesizeKey("x", { type: "keyup" }); + EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }); + + Assert.equal( + gURLBar.inputField.value, + TEST_VALUE, + "The value of urlbar is correct" + ); + + await onLoad; + Assert.ok("Browser loaded the correct url"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function keyupEnterWhilePressingMeta() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Keydown Meta+Enter"); + gURLBar.focus(); + gURLBar.value = ""; + EventUtils.synthesizeKey("KEY_Enter", { type: "keydown", metaKey: true }); + + // Pressing Enter key while pressing Meta key, and next, even when releasing + // Enter key before releasing Meta key, the keyup event is not fired. + // Therefor, we fire Meta keyup event only. + info("Keyup Meta"); + EventUtils.synthesizeKey("KEY_Meta", { type: "keyup" }); + + // Check whether we can input on URL bar. + EventUtils.synthesizeKey("a"); + is(gURLBar.value, "a", "Can input a char"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_enterAfterMouseOver.js b/browser/components/urlbar/tests/browser/browser_enterAfterMouseOver.js new file mode 100644 index 0000000000..e102fda09c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_enterAfterMouseOver.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that enter works correctly after a mouse over. + */ + +function repeat(limit, func) { + for (let i = 0; i < limit; i++) { + func(i); + } +} + +async function promiseAutoComplete(inputText) { + gURLBar.focus(); + gURLBar.value = inputText.slice(0, -1); + EventUtils.sendString(inputText.slice(-1)); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +function assertSelected(index) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + index, + "Should have the correct index selected" + ); +} + +let gMaxResults; + +add_task(async function () { + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + await PlacesUtils.history.clear(); + + gMaxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + + let visits = []; + repeat(gMaxResults, i => { + visits.push({ + uri: makeURI("http://example.com/autocomplete/?" + i), + }); + }); + await PlacesTestUtils.addVisits(visits); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await promiseAutoComplete("http://example.com/autocomplete/"); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + gMaxResults, + "Should have got the correct amount of results" + ); + + let initiallySelected = UrlbarTestUtils.getSelectedRowIndex(window); + + info("Key Down to select the next item"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertSelected(initiallySelected + 1); + + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + initiallySelected + 1 + ); + let expectedURL = result.url; + + Assert.equal( + gURLBar.untrimmedValue, + expectedURL, + "Value in the URL bar should be updated by keyboard selection" + ); + + // Verify that what we're about to do changes the selectedIndex: + Assert.notEqual( + initiallySelected + 1, + 3, + "Shouldn't be changing the selectedIndex to the same index we keyboard-selected." + ); + + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 3); + EventUtils.synthesizeMouseAtCenter(element, { type: "mousemove" }); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + let openedExpectedPage = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURL, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await openedExpectedPage; + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_focusedCmdK.js b/browser/components/urlbar/tests/browser/browser_focusedCmdK.js new file mode 100644 index 0000000000..fc32c2c13c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_focusedCmdK.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + // Test that Ctrl/Cmd + K will focus the url bar + let focusPromise = BrowserTestUtils.waitForEvent(gURLBar, "focus"); + document.documentElement.focus(); + EventUtils.synthesizeKey("k", { accelKey: true }); + await focusPromise; + Assert.equal( + document.activeElement, + gURLBar.inputField, + "URL Bar should be focused" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_groupLabels.js b/browser/components/urlbar/tests/browser/browser_groupLabels.js new file mode 100644 index 0000000000..2b43990b77 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_groupLabels.js @@ -0,0 +1,629 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests group labels in the view. + +"use strict"; + +const SUGGESTIONS_FIRST_PREF = "browser.urlbar.showSearchSuggestionsFirst"; +const SUGGESTIONS_PREF = "browser.urlbar.suggest.searches"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const TEST_ENGINE_2_BASENAME = "searchSuggestionEngine2.xml"; +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); + +const TOP_SITES = [ + "http://example-1.com/", + "http://example-2.com/", + "http://example-3.com/", +]; + +const FIREFOX_SUGGEST_LABEL = "Firefox Suggest"; + +// %s is replaced with the engine name. +const ENGINE_SUGGESTIONS_LABEL = "%s suggestions"; + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + Assert.ok( + UrlbarPrefs.get("showSearchSuggestionsFirst"), + "Precondition: Search suggestions shown first by default" + ); + + // Add some history. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + await addHistory(); + + // Make sure we have some top sites. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", TOP_SITES.join(",")], + ], + }); + // Waiting for all top sites to be added intermittently times out, so just + // wait for any to be added. We're not testing top sites here; we only need + // the view to open in top-sites mode. + await updateTopSites(sites => sites && sites.length); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// The Firefox Suggest label should not appear when the labels pref is disabled. +add_task(async function prefDisabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.groupLabels.enabled", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, {}); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); + +// The Firefox Suggest label should not appear when the view shows top sites. +add_task(async function topSites() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await checkLabels(-1, {}); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// The Firefox Suggest label should appear when the search string is non-empty +// and there are only general results. +add_task(async function general() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + }); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// The Firefox Suggest label should appear when the search string is non-empty +// and there are suggestions followed by general results. +add_task(async function suggestionsBeforeGeneral() { + await withSuggestions(async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, { + 3: FIREFOX_SUGGEST_LABEL, + }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Both the Firefox Suggest and Suggestions labels should appear when the search +// string is non-empty, general results are shown before suggestions, and there +// are general and suggestion results. +add_task(async function generalBeforeSuggestions() { + await withSuggestions(async engine => { + Assert.ok(engine.name, "Engine name is non-empty"); + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + [MAX_RESULTS - 2]: engineSuggestionsLabel(engine.name), + }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Neither the Firefox Suggest nor Suggestions label should appear when the +// search string is non-empty, general results are shown before suggestions, and +// there are only suggestion results. +add_task(async function generalBeforeSuggestions_suggestionsOnly() { + await PlacesUtils.history.clear(); + + await withSuggestions(async engine => { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(3, {}); + await UrlbarTestUtils.promisePopupClose(window); + }); + + // Add back history so subsequent tasks run with this test's initial state. + await addHistory(); +}); + +// The Suggestions label should be updated when the default engine changes. +add_task(async function generalBeforeSuggestions_defaultChanged() { + // Install both test engines, one after the other. Engine 2 will be the final + // default engine. + await withSuggestions(async engine1 => { + await withSuggestions(async engine2 => { + Assert.ok(engine2.name, "Engine 2 name is non-empty"); + Assert.notEqual(engine1.name, engine2.name, "Engine names are different"); + Assert.equal( + Services.search.defaultEngine.name, + engine2.name, + "Engine 2 is default" + ); + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + [MAX_RESULTS - 2]: engineSuggestionsLabel(engine2.name), + }); + await UrlbarTestUtils.promisePopupClose(window); + }, TEST_ENGINE_2_BASENAME); + }); +}); + +// The Firefox Suggest label should appear above a suggested-index result when +// the result is the only result with that label. +add_task(async function suggestedIndex_only() { + // Clear history, add a provider that returns a result with suggestedIndex = + // -1, set up an engine with suggestions, and start a query. The suggested- + // index result will be the only result with a label. + await PlacesUtils.history.clear(); + + let index = -1; + let provider = new SuggestedIndexProvider(index); + UrlbarProvidersManager.registerProvider(provider); + + await withSuggestions(async engine => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 3); + Assert.equal( + result.element.row.result.suggestedIndex, + index, + "Sanity check: Our suggested-index result is present" + ); + await checkLabels(4, { + 3: FIREFOX_SUGGEST_LABEL, + }); + await UrlbarTestUtils.promisePopupClose(window); + }); + + UrlbarProvidersManager.unregisterProvider(provider); + + // Add back history so subsequent tasks run with this test's initial state. + await addHistory(); +}); + +// The Firefox Suggest label should appear above a suggested-index result when +// the result is the first but not the only result with that label. +add_task(async function suggestedIndex_first() { + let index = 1; + let provider = new SuggestedIndexProvider(index); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + result.element.row.result.suggestedIndex, + index, + "Sanity check: Our suggested-index result is present" + ); + await checkLabels(MAX_RESULTS, { + [index]: FIREFOX_SUGGEST_LABEL, + }); + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// The Firefox Suggest label should not appear above a suggested-index result +// when the result is not the first with that label. +add_task(async function suggestedIndex_notFirst() { + let index = -1; + let provider = new SuggestedIndexProvider(index); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + MAX_RESULTS + index + ); + Assert.equal( + result.element.row.result.suggestedIndex, + index, + "Sanity check: Our suggested-index result is present" + ); + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + }); + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Labels that appear multiple times but not consecutively should be shown. +add_task(async function repeatLabels() { + let engineName = Services.search.defaultEngine.name; + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/1" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { suggestion: "test1", engine: engineName } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/2" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { suggestion: "test2", engine: engineName } + ), + ]; + + for (let i = 0; i < results.length; i++) { + results[i].suggestedIndex = i; + } + + let provider = new UrlbarTestUtils.TestProvider({ + results, + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(results.length, { + 0: FIREFOX_SUGGEST_LABEL, + 1: engineSuggestionsLabel(engineName), + 2: FIREFOX_SUGGEST_LABEL, + 3: engineSuggestionsLabel(engineName), + }); + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Clicking a row label shouldn't do anything. +add_task(async function clickLabel() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search. The mock history added in init() should appear with the + // Firefox Suggest label at index 1. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + }); + + // Check the result at index 2. + let result2 = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.ok(result2.url, "Result at index 2 has a URL"); + let url2 = result2.url; + Assert.ok( + url2.startsWith("http://example.com/"), + "Result at index 2 is one of our mock history results" + ); + + // Get the row at index 3 and click above it. The click should hit the row + // at index 2 and load its URL. We do this to make sure our click code + // here in the test works properly and that performing a similar click + // relative to index 1 (see below) would hit the row at index 0 if not for + // the label at index 1. + let result3 = await UrlbarTestUtils.getDetailsOfResultAt(window, 3); + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + info("Performing click relative to index 3"); + await UrlbarTestUtils.promisePopupClose(window, () => + click(result3.element.row, { y: -2 }) + ); + info("Waiting for load after performing click relative to index 3"); + await loadPromise; + Assert.equal(gBrowser.currentURI.spec, url2, "Loaded URL at index 2"); + // Now do the search again. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + await checkLabels(MAX_RESULTS, { + 1: FIREFOX_SUGGEST_LABEL, + }); + + // Check the result at index 1, the one with the label. + let result1 = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.ok(result1.url, "Result at index 1 has a URL"); + let url1 = result1.url; + Assert.ok( + url1.startsWith("http://example.com/"), + "Result at index 1 is one of our mock history results" + ); + Assert.notEqual(url1, url2, "URLs at indexes 1 and 2 are different"); + + // Do a click on the row at index 1 in the same way as before. This time + // nothing should happen because the click should hit the label, not the + // row at index 0. + info("Clicking row label at index 1"); + click(result1.element.row, { y: -2 }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 500)); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "View remains open"); + Assert.equal( + gBrowser.currentURI.spec, + url2, + "Current URL is still URL from index 2" + ); + + // Now click the main part of the row at index 1. Its URL should load. + loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + let { height } = result1.element.row.getBoundingClientRect(); + info(`Clicking main part of the row at index 1, height=${height}`); + await UrlbarTestUtils.promisePopupClose(window, () => + click(result1.element.row) + ); + info("Waiting for load after clicking row at index 1"); + await loadPromise; + Assert.equal(gBrowser.currentURI.spec, url1, "Loaded URL at index 1"); + }); +}); + +add_task(async function ariaLabel() { + const helpUrl = "http://example.com/help"; + const results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/1", helpUrl } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/2", helpUrl } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/3" } + ), + ]; + + for (let i = 0; i < results.length; i++) { + results[i].suggestedIndex = i; + } + + const provider = new UrlbarTestUtils.TestProvider({ + results, + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await checkLabels(results.length, { + 0: FIREFOX_SUGGEST_LABEL, + }); + + const expectedRows = [ + { hasGroupAriaLabel: true, ariaLabel: FIREFOX_SUGGEST_LABEL }, + { hasGroupAriaLabel: false }, + { hasGroupAriaLabel: false }, + ]; + await checkGroupAriaLabels(expectedRows); + + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +/** + * Provider that returns a suggested-index result. + */ +class SuggestedIndexProvider extends UrlbarTestUtils.TestProvider { + constructor(suggestedIndex) { + super({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/" } + ), + { suggestedIndex } + ), + ], + }); + } +} + +async function addHistory() { + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } +} + +/** + * Asserts that each result in the view does or doesn't have a label, depending + * on `labelsByIndex`. + * + * @param {number} resultCount + * The expected number of results. Pass -1 to use the max index in + * `labelsByIndex` or the actual result count if `labelsByIndex` is empty. + * @param {object} labelsByIndex + * A mapping from indexes to expected labels. + */ +async function checkLabels(resultCount, labelsByIndex) { + if (resultCount >= 0) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "Expected result count" + ); + } else { + // This `else` branch is only necessary because waiting for all top sites to + // be added intermittently times out. Don't let the test fail for such a + // dumb reason. + let indexes = Object.keys(labelsByIndex); + if (indexes.length) { + resultCount = indexes.sort((a, b) => b - a)[0] + 1; + } else { + resultCount = UrlbarTestUtils.getResultCount(window); + Assert.greater(resultCount, 0, "Actual result count is > 0"); + } + } + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + let { row } = result.element; + let before = getComputedStyle(row, "::before"); + if (labelsByIndex.hasOwnProperty(i)) { + Assert.equal( + before.content, + "attr(label)", + `::before.content is correct at index ${i}` + ); + Assert.equal( + row.getAttribute("label"), + labelsByIndex[i], + `Row has correct label at index ${i}` + ); + } else { + Assert.equal( + before.content, + "none", + `::before.content is 'none' at index ${i}` + ); + Assert.ok( + !row.hasAttribute("label"), + `Row does not have label attribute at index ${i}` + ); + } + } +} + +/** + * Asserts that an element for group aria label. + * + * @param {Array} expectedRows The expected rows. + */ +async function checkGroupAriaLabels(expectedRows) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedRows.length, + "Expected result count" + ); + + for (let i = 0; i < expectedRows.length; i++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + const { row } = result.element; + const groupAriaLabel = row.querySelector(".urlbarView-group-aria-label"); + + const expected = expectedRows[i]; + + Assert.equal( + !!groupAriaLabel, + expected.hasGroupAriaLabel, + `Group aria label exists as expected in the results[${i}]` + ); + + if (expected.hasGroupAriaLabel) { + Assert.equal( + groupAriaLabel.getAttribute("aria-label"), + expected.ariaLabel, + `Content of aria-label attribute in the element for group aria label in the results[${i}] is correct` + ); + } + } +} + +function engineSuggestionsLabel(engineName) { + return ENGINE_SUGGESTIONS_LABEL.replace("%s", engineName); +} + +/** + * Adds a search engine that provides suggestions, calls your callback, and then + * remove the engine. + * + * @param {Function} callback + * Your callback function. + * @param {string} [engineBasename] + * The basename of the engine file. + */ +async function withSuggestions( + callback, + engineBasename = TEST_ENGINE_BASENAME +) { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_PREF, true]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + engineBasename, + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + try { + await callback(engine); + } finally { + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(engine); + await SpecialPowers.popPrefEnv(); + } +} + +function click(element, { x = undefined, y = undefined } = {}) { + let { width, height } = element.getBoundingClientRect(); + if (typeof x != "number") { + x = width / 2; + } + if (typeof y != "number") { + y = height / 2; + } + EventUtils.synthesizeMouse(element, x, y, { type: "mousedown" }); + EventUtils.synthesizeMouse(element, x, y, { type: "mouseup" }); +} diff --git a/browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js b/browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js new file mode 100644 index 0000000000..9d8ac8754c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the fallback paths of handleCommand (no view and no previous + * result) work consistently against the normal case of picking the heuristic + * result. + */ + +const TEST_STRINGS = [ + "test", + "test/", + "test.com", + "test.invalid", + "moz", + "moz test", + "@moz test", + "keyword", + "keyword test", + "test/test/", + "test /test/", +]; + +add_task(async function () { + // Disable autofill so mozilla.org isn't autofilled below. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + sandbox = sinon.createSandbox(); + await SearchTestUtils.installSearchExtension(); + await SearchTestUtils.installSearchExtension({ name: "Example2" }); + + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/?q=%s", + title: "test", + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "https://example.com/?q=%s", + }); + registerCleanupFunction(async () => { + sandbox.restore(); + await PlacesUtils.bookmarks.remove(bm); + await UrlbarTestUtils.formHistory.clear(); + }); + + async function promiseLoadURL() { + return new Promise(resolve => { + sandbox.stub(gURLBar, "_loadURL").callsFake(function () { + sandbox.restore(); + // The last arguments are optional and apply only to some cases, so we + // could not use deepEqual with them. + resolve(Array.from(arguments).slice(0, 3)); + }); + }); + } + + // Run the string through a normal search where the user types the string + // and confirms the heuristic result, store the arguments to _loadURL, then + // confirm the same string without a view and without an input event, and + // compare the arguments. + for (let value of TEST_STRINGS) { + info(`Input the value normally and Enter. Value: ${value}`); + let promise = promiseLoadURL(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + EventUtils.synthesizeKey("KEY_Enter"); + let args = await promise; + Assert.ok(args.length, "Sanity check"); + info("Close the panel and confirm again."); + promise = promiseLoadURL(); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.deepEqual(await promise, args, "Check arguments are coherent"); + + info("Set the value directly and Enter."); + // To properly testing the original value we must be out of search mode. + if (gURLBar.searchMode) { + await UrlbarTestUtils.exitSearchMode(window); + // Exiting search mode may reopen the panel. + await UrlbarTestUtils.promisePopupClose(window); + } + promise = promiseLoadURL(); + gURLBar.value = value; + let spy = sinon.spy(UrlbarUtils, "getHeuristicResultFor"); + EventUtils.synthesizeKey("KEY_Enter"); + spy.restore(); + Assert.ok(spy.called, "invoked getHeuristicResultFor"); + Assert.deepEqual(await promise, args, "Check arguments are coherent"); + gURLBar.handleRevert(); + } +}); + +// This is testing the final fallback case that may happen when we can't +// get a heuristic result, maybe because the Places database is corrupt. +add_task(async function no_heuristic_test() { + sandbox = sinon.createSandbox(); + + let stub = sandbox + .stub(UrlbarUtils, "getHeuristicResultFor") + .callsFake(async function () { + throw new Error("I failed!"); + }); + + registerCleanupFunction(async () => { + sandbox.restore(); + await UrlbarTestUtils.formHistory.clear(); + }); + + async function promiseLoadURL() { + return new Promise(resolve => { + sandbox.stub(gURLBar, "_loadURL").callsFake(function () { + sandbox.restore(); + // The last arguments are optional and apply only to some cases, so we + // could not use deepEqual with them. + resolve(Array.from(arguments).slice(0, 3)); + }); + }); + } + + // Run the string through a normal search where the user types the string + // and confirms the heuristic result, store the arguments to _loadURL, then + // confirm the same string without a view and without an input event, and + // compare the arguments. + for (let value of TEST_STRINGS) { + // To properly testing the original value we must be out of search mode. + if (gURLBar.searchMode) { + await UrlbarTestUtils.exitSearchMode(window); + } + let promise = promiseLoadURL(); + gURLBar.value = value; + EventUtils.synthesizeKey("KEY_Enter"); + Assert.ok(stub.called, "invoked getHeuristicResultFor"); + // The first argument to _loadURL should always be a valid url, so this + // should never throw. + new URL((await promise)[0]); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_hashChangeProxyState.js b/browser/components/urlbar/tests/browser/browser_hashChangeProxyState.js new file mode 100644 index 0000000000..d0e236fe7e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_hashChangeProxyState.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that navigating through both the URL bar and using in-page hash- or ref- + * based links and back or forward navigation updates the URL bar and identity block correctly. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let baseURL = `${TEST_BASE_URL}dummy_page.html`; + let url = baseURL + "#foo"; + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + let identityBox = document.getElementById("identity-box"); + let expectedURL = url; + + let verifyURLBarState = testType => { + is( + gURLBar.value, + expectedURL, + "URL bar visible value should be correct " + testType + ); + is( + gURLBar.untrimmedValue, + expectedURL, + "URL bar value should be correct " + testType + ); + ok( + identityBox.classList.contains("verifiedDomain"), + "Identity box should know we're doing SSL " + testType + ); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "URL bar is in valid page proxy state" + ); + }; + + verifyURLBarState("at the beginning"); + + let locationChangePromise; + let resolveLocationChangePromise; + let expectURL = urlTemp => { + expectedURL = urlTemp; + locationChangePromise = new Promise( + r => (resolveLocationChangePromise = r) + ); + }; + let wpl = { + onLocationChange(unused, unused2, location) { + is(location.spec, expectedURL, "Got the expected URL"); + resolveLocationChangePromise(); + }, + }; + gBrowser.addProgressListener(wpl); + + expectURL(baseURL + "#foo"); + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + verifyURLBarState("after hitting enter on the same URL a second time"); + + expectURL(baseURL + "#bar"); + gURLBar.value = expectedURL; + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + verifyURLBarState("after a URL bar hash navigation"); + + expectURL(baseURL + "#foo"); + await SpecialPowers.spawn(browser, [], function () { + let a = content.document.createElement("a"); + a.href = "#foo"; + a.textContent = "Foo Link"; + content.document.body.appendChild(a); + a.click(); + }); + + await locationChangePromise; + verifyURLBarState("after a page link hash navigation"); + + expectURL(baseURL + "#bar"); + gBrowser.goBack(); + + await locationChangePromise; + verifyURLBarState("after going back"); + + expectURL(baseURL + "#foo"); + gBrowser.goForward(); + + await locationChangePromise; + verifyURLBarState("after going forward"); + + expectURL(baseURL + "#foo"); + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + verifyURLBarState("after hitting enter on the same URL"); + + gBrowser.removeProgressListener(wpl); + } + ); +}); + +/** + * Check that initial secure loads that swap remoteness + * get the correct page icon when finished. + */ +add_task(async function () { + // Ensure there's no preloaded newtab browser, since that'll not fire a load event. + NewTabPagePreloading.removePreloadedBrowser(window); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab" + ); + let url = `${TEST_BASE_URL}dummy_page.html#foo`; + gURLBar.value = url; + gURLBar.select(); + EventUtils.sendKey("return"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + is( + gURLBar.value, + url, + "URL bar visible value should be correct when the page loads from about:newtab" + ); + is( + gURLBar.untrimmedValue, + url, + "URL bar value should be correct when the page loads from about:newtab" + ); + let identityBox = document.getElementById("identity-box"); + ok( + identityBox.classList.contains("verifiedDomain"), + "Identity box should know we're doing SSL when the page loads from about:newtab" + ); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "URL bar is in valid page proxy state when SSL page with hash loads from about:newtab" + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_helpUrl.js b/browser/components/urlbar/tests/browser/browser_helpUrl.js new file mode 100644 index 0000000000..5182a8ddb0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_helpUrl.js @@ -0,0 +1,428 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the help/info button that appears for results whose payloads have a +// `helpUrl` property. + +"use strict"; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); +const RESULT_URL = "http://example.com/test"; +const RESULT_HELP_URL = "http://example.com/help"; + +add_setup(async function () { + // Add enough results to fill up the view. + await PlacesUtils.history.clear(); + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Sets `helpL10n` on the result payload and makes sure the help button ends +// up with a corresponding l10n attribute. +add_task(async function title_helpL10n() { + if (UrlbarPrefs.get("resultMenu")) { + return; + } + let provider = registerTestProvider(1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + await assertIsTestResult(1); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let helpButton = result.element.row._buttons.get("help"); + Assert.ok(helpButton, "Sanity check: help button should exist"); + + let l10nAttrs = document.l10n.getAttributes(helpButton); + Assert.deepEqual( + l10nAttrs, + { id: "urlbar-tip-help-icon", args: null }, + "The l10n ID attribute was correctly set" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// (SHIFT+)TABs through a result with a help button. The result is the +// second result and has other results after it. +add_task(async function keyboardSelection_secondResult() { + let provider = registerTestProvider(1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + MAX_RESULTS, + "There should be MAX_RESULTS results in the view" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(1); + + info("Arrow down to the main part of the result."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertMainPartSelected(1); + + info("TAB to the button."); + EventUtils.synthesizeKey("KEY_Tab"); + assertButtonSelected(2); + + info("TAB to the next (third) result."); + EventUtils.synthesizeKey("KEY_Tab"); + assertOtherResultSelected(3, "next result"); + + info("SHIFT+TAB to the help button."); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertButtonSelected(2); + + info("SHIFT+TAB to the main part of the result."); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertMainPartSelected(1); + + info("Arrow up to the previous (first) result."); + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertOtherResultSelected(0, "previous result"); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// (SHIFT+)TABs through a result with a help button. The result is the +// last result. +add_task(async function keyboardSelection_lastResult() { + let provider = registerTestProvider(MAX_RESULTS - 1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + MAX_RESULTS, + "There should be MAX_RESULTS results in the view" + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(MAX_RESULTS - 1); + + let numSelectable = UrlbarPrefs.get("resultMenu") + ? MAX_RESULTS * 2 - 2 + : MAX_RESULTS; + + // Arrow down to the main part of the result. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: MAX_RESULTS - 1 }); + assertMainPartSelected(numSelectable - 1); + + // TAB to the help button. + EventUtils.synthesizeKey("KEY_Tab"); + assertButtonSelected(numSelectable); + + // Arrow down to the first one-off. If this test is running alone, the + // one-offs will rebuild themselves when the view is opened above, and they + // may not be visible yet. Wait for the first one to become visible before + // trying to select it. + await TestUtils.waitForCondition(() => { + return ( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild && + BrowserTestUtils.is_visible( + gURLBar.view.oneOffSearchButtons.buttons.firstElementChild + ) + ); + }, "Waiting for first one-off to become visible."); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + await TestUtils.waitForCondition(() => { + return gURLBar.view.oneOffSearchButtons.selectedButton; + }, "Waiting for one-off to become selected."); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + -1, + "No results should be selected." + ); + + // SHIFT+TAB to the help button. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertButtonSelected(numSelectable); + + // SHIFT+TAB to the main part of the result. + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + assertMainPartSelected(numSelectable - 1); + + // Arrow up to the previous result. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertOtherResultSelected( + numSelectable - (UrlbarPrefs.get("resultMenu") ? 3 : 2), + "previous result" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Picks the main part of the test result -- the non-help-button part -- with +// the keyboard. +add_task(async function pick_mainPart_keyboard() { + await doPickTest({ pickButton: false, useKeyboard: true }); +}); + +// Picks the help button with the keyboard. +add_task(async function pick_helpButton_keyboard() { + await doPickTest({ pickButton: true, useKeyboard: true }); +}); + +// Picks the main part of the test result -- the non-help-button part -- with +// the mouse. +add_task(async function pick_mainPart_mouse() { + await doPickTest({ pickButton: false, useKeyboard: false }); +}); + +// Picks the help button with the mouse. +add_task(async function pick_helpButton_mouse() { + await doPickTest({ pickButton: true, useKeyboard: false }); +}); + +async function doPickTest({ pickButton, useKeyboard }) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let index = 1; + let provider = registerTestProvider(index); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "example", + window, + }); + + // Sanity-check initial state. + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The heuristic result should be selected" + ); + await assertIsTestResult(index); + + if (useKeyboard) { + // Arrow down to the result. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: index }); + assertMainPartSelected( + UrlbarPrefs.get("resultMenu") ? index * 2 - 1 : index + ); + } + + // Pick the result. The appropriate URL should load. + let loadPromise = pickButton + ? BrowserTestUtils.waitForNewTab(gBrowser) + : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await Promise.all([ + loadPromise, + UrlbarTestUtils.promisePopupClose(window, async () => { + if (pickButton && UrlbarPrefs.get("resultMenu")) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "h", { + openByMouse: !useKeyboard, + resultIndex: index, + }); + } else if (useKeyboard) { + if (pickButton) { + // TAB to the button. + EventUtils.synthesizeKey("KEY_Tab"); + assertButtonSelected(index + 1); + } + EventUtils.synthesizeKey("KEY_Enter"); + } else { + // Get the click target. + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + let clickTarget = pickButton + ? result.element.row._buttons.get("help") + : result.element.row._content; + Assert.ok( + clickTarget, + "Click target found, pickButton=" + pickButton + ); + EventUtils.synthesizeMouseAtCenter(clickTarget, {}); + } + }), + ]); + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + pickButton ? RESULT_HELP_URL : RESULT_URL, + "Expected URL should have loaded" + ); + + if (pickButton) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + UrlbarProvidersManager.unregisterProvider(provider); + + // Avoid showing adaptive history autofill. + await PlacesTestUtils.clearInputHistory(); + }); +} + +/** + * Registers a provider that creates a result with a help button. + * + * @param {number} suggestedIndex + * The result's suggestedIndex. + * @returns {UrlbarProvider} + * The new provider. + */ +function registerTestProvider(suggestedIndex) { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: RESULT_URL, + helpUrl: RESULT_HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }, + } + ), + { suggestedIndex } + ), + ]; + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + return provider; +} + +/** + * Asserts that the result at the given index is our test result with a help + * button. + * + * @param {number} index + * The expected index of the test result. + */ +async function assertIsTestResult(index) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "The second result should be a URL" + ); + Assert.equal( + result.url, + RESULT_URL, + "The result's URL should be the expected URL" + ); + + let { row } = result.element; + if (UrlbarPrefs.get("resultMenu")) { + Assert.ok(row._buttons.get("menu"), "The result should have a menu button"); + } else { + let helpButton = row._buttons.get("help"); + Assert.ok(helpButton, "The result should have a help button"); + Assert.ok(helpButton.id, "Help button has an ID"); + } + Assert.ok(row._content.id, "Row-inner has an ID"); + Assert.equal( + row.getAttribute("role"), + "presentation", + "Row should have role=presentation" + ); + Assert.equal( + row._content.getAttribute("role"), + "option", + "Row-inner should have role=option" + ); +} + +/** + * Asserts that a particular element is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + * @param {string} expectedClassName + * A class name of the expected selected element. + * @param {string} msg + * A string to include in the assertion message. + */ +function assertSelection(expectedSelectedElementIndex, expectedClassName, msg) { + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + expectedSelectedElementIndex, + "Expected selected element index: " + msg + ); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + expectedClassName + ), + `Expected selected element: ${msg} (${ + UrlbarTestUtils.getSelectedElement(window).classList + } == ${expectedClassName})` + ); +} + +/** + * Asserts that the main part of our test resut -- the non-help-button part -- + * is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + */ +function assertMainPartSelected(expectedSelectedElementIndex) { + assertSelection( + expectedSelectedElementIndex, + "urlbarView-row-inner", + "main part of test result" + ); +} + +/** + * Asserts that the help button part of our test result is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + */ +function assertButtonSelected(expectedSelectedElementIndex) { + if (UrlbarPrefs.get("resultMenu")) { + assertSelection( + expectedSelectedElementIndex, + "urlbarView-button-menu", + "menu button" + ); + } else { + assertSelection( + expectedSelectedElementIndex, + "urlbarView-button-help", + "help button" + ); + } +} + +/** + * Asserts that a result other than our test result is selected. + * + * @param {number} expectedSelectedElementIndex + * The expected selected element index. + * @param {string} msg + * A string to include in the assertion message. + */ +function assertOtherResultSelected(expectedSelectedElementIndex, msg) { + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + expectedSelectedElementIndex, + "Expected other selected element index: " + msg + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js b/browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js new file mode 100644 index 0000000000..fa7c65b378 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// When the heuristic result is not the first result added, it should still be +// selected. + +"use strict"; + +// When the heuristic result is not the first result added, it should still be +// selected. +add_task(async function slowHeuristicSelected() { + // First, add a provider that adds a heuristic result on a delay. Both this + // provider and the one below have a high priority so that only they are used + // during the test. + let engine = await Services.search.getDefault(); + let heuristicResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: "test", + engine: engine.name, + } + ); + heuristicResult.heuristic = true; + let heuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [heuristicResult], + name: "heuristicProvider", + priority: Infinity, + addTimeout: 500, + }); + UrlbarProvidersManager.registerProvider(heuristicProvider); + + // Second, add another provider that adds a non-heuristic result immediately + // with suggestedIndex = 1. + let nonHeuristicResult = makeTipResult(); + nonHeuristicResult.suggestedIndex = 1; + let nonHeuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [nonHeuristicResult], + name: "nonHeuristicProvider", + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(nonHeuristicProvider); + + // Do a search. + const win = await BrowserTestUtils.openNewBrowserWindow(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window: win, + }); + + // The first result should be the heuristic and it should be selected. + let actualHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal(actualHeuristic.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(UrlbarTestUtils.getSelectedElementIndex(win), 0); + + // Check the second result for good measure. + let actualNonHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(win, 1); + Assert.equal(actualNonHeuristic.type, UrlbarUtils.RESULT_TYPE.TIP); + + await UrlbarTestUtils.promisePopupClose(win); + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + UrlbarProvidersManager.unregisterProvider(nonHeuristicProvider); + await BrowserTestUtils.closeWindow(win); +}); + +// When the heuristic result is not the first result added but a one-off search +// button is already selected, the heuristic result should not steal the +// selection from the one-off button. +add_task(async function oneOffRemainsSelected() { + // First, add a provider that adds a heuristic result on a delay. Both this + // provider and the one below have a high priority so that only they are used + // during the test. + let engine = await Services.search.getDefault(); + let heuristicResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + suggestion: "test", + engine: engine.name, + } + ); + heuristicResult.heuristic = true; + let heuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [heuristicResult], + name: "heuristicProvider", + priority: Infinity, + addTimeout: 500, + }); + UrlbarProvidersManager.registerProvider(heuristicProvider); + + // Second, add another provider that adds a non-heuristic result immediately + // with suggestedIndex = 1. + let nonHeuristicResult = makeTipResult(); + nonHeuristicResult.suggestedIndex = 1; + let nonHeuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [nonHeuristicResult], + name: "nonHeuristicProvider", + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(nonHeuristicProvider); + + // Do a search but don't wait for it to finish. + const win = await BrowserTestUtils.openNewBrowserWindow(); + let searchPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window: win, + }); + + // When the view opens, press the up arrow key to select the one-off search + // settings button. There's no point in selecting instead the non-heuristic + // result because once we do that, the search is canceled, and the heuristic + // result will never be added. + await UrlbarTestUtils.promisePopupOpen(win, () => {}); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + + // Wait for the search to finish. + await searchPromise; + + // The first result should be the heuristic. + let actualHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal(actualHeuristic.type, UrlbarUtils.RESULT_TYPE.SEARCH); + + // Check the second result for good measure. + let actualNonHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(win, 1); + Assert.equal(actualNonHeuristic.type, UrlbarUtils.RESULT_TYPE.TIP); + + // No result should be selected. + Assert.equal(UrlbarTestUtils.getSelectedElement(win), null); + Assert.equal(UrlbarTestUtils.getSelectedElementIndex(win), -1); + + // The one-off settings button should be selected. + Assert.equal( + win.gURLBar.view.oneOffSearchButtons.selectedButton, + win.gURLBar.view.oneOffSearchButtons.settingsButton + ); + + await UrlbarTestUtils.promisePopupClose(win); + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + UrlbarProvidersManager.unregisterProvider(nonHeuristicProvider); + await BrowserTestUtils.closeWindow(win); +}); + +function makeTipResult() { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_hideHeuristic.js b/browser/components/urlbar/tests/browser/browser_hideHeuristic.js new file mode 100644 index 0000000000..e8f8774e01 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_hideHeuristic.js @@ -0,0 +1,513 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Basic smoke tests for the `browser.urlbar.experimental.hideHeuristic` pref, +// which hides the heuristic result. Each task performs a search that triggers a +// specific heuristic, verifies that it's hidden or shown as appropriate, and +// verifies that it's picked when enter is pressed. +// +// If/when it becomes the default, we should update existing tests as necessary +// and remove this one. + +"use strict"; + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.experimental.hideHeuristic", true], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION should be hidden. +add_task(async function extension() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Add an extension provider that returns a heuristic. + let url = "http://example.com/extension-test"; + let provider = new UrlbarTestUtils.TestProvider({ + name: "ExtensionTest", + type: UrlbarUtils.PROVIDER_TYPE.EXTENSION, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url, + title: "Test", + } + ), + { heuristic: true } + ), + ], + }); + UrlbarProvidersManager.registerProvider(provider); + + // Do a search that fetches the provider's result and check it. + let heuristic = await search({ + value: "test", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION, + }); + Assert.equal(heuristic.payload.url, url, "Heuristic URL is correct"); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad(url); + + UrlbarProvidersManager.unregisterProvider(provider); + }); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX should be hidden. +add_task(async function omnibox() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Load an extension. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + }, + background() { + /* global browser */ + browser.omnibox.onInputEntered.addListener(() => { + browser.test.sendMessage("onInputEntered"); + }); + }, + }); + await extension.startup(); + + // Do a search using the omnibox keyword and check the hidden heuristic + // result. + let heuristic = await search({ + value: "omniboxtest foo", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX, + }); + Assert.equal( + heuristic.payload.keyword, + "omniboxtest", + "Heuristic keyword is correct" + ); + + // Press enter to verify the heuristic result is picked. + let messagePromise = extension.awaitMessage("onInputEntered"); + EventUtils.synthesizeKey("KEY_Enter"); + await messagePromise; + + await extension.unload(); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP should be shown. +add_task(async function searchTip() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.searchTips.test.ignoreShowLimits", true]], + }); + await BrowserTestUtils.withNewTab( + { + gBrowser: window.gBrowser, + url: "about:newtab", + // `withNewTab` hangs waiting for about:newtab to load without this. + waitForLoad: false, + }, + async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => {}); + Assert.ok(true, "View opened"); + Assert.equal(UrlbarTestUtils.getResultCount(window), 1); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TIP); + Assert.ok(result.heuristic); + Assert.ok(UrlbarTestUtils.getSelectedElement(window), "Selection exists"); + } + ); + await SpecialPowers.popPrefEnv(); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS should be hidden. +add_task(async function engineAlias() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Add an engine with an alias. + await withEngine({ keyword: "test" }, async () => { + // Do a search using the alias and check the hidden heuristic result. + // The heuristic will be HEURISTIC_FALLBACK, not HEURISTIC_ENGINE_ALIAS, + // because two searches are performed and + // `UrlbarTestUtils.promiseAutocompleteResultPopup` waits for both. The + // first returns a HEURISTIC_ENGINE_ALIAS that triggers search mode and + // then an immediate second search, which returns HEURISTIC_FALLBACK. + let heuristic = await search({ + value: "test foo", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK, + }); + Assert.equal( + heuristic.payload.engine, + "Example", + "Heuristic engine is correct" + ); + Assert.equal( + heuristic.payload.query, + "foo", + "Heuristic query is correct" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "Example", + entry: "typed", + }); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad("https://example.com/?q=foo"); + }); + }); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD should be hidden. +add_task(async function bookmarkKeyword() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Add a bookmark with a keyword. + let keyword = "bm"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/?q=%s", + title: "test", + }); + await PlacesUtils.keywords.insert({ keyword, url: bm.url }); + + // Do a search using the keyword and check the hidden heuristic result. + let heuristic = await search({ + value: "bm foo", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD, + }); + Assert.equal( + heuristic.payload.keyword, + keyword, + "Heuristic keyword is correct" + ); + let heuristicURL = "http://example.com/?q=foo"; + Assert.equal( + heuristic.payload.url, + heuristicURL, + "Heuristic URL is correct" + ); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad(heuristicURL); + + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(window); + }); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL should be hidden. +add_task(async function autofill() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Do a search that triggers autofill and check the hidden heuristic + // result. + let heuristic = await search({ + value: "ex", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL, + }); + Assert.ok(heuristic.autofill, "Heuristic is autofill"); + let heuristicURL = "http://example.com/"; + Assert.equal( + heuristic.payload.url, + heuristicURL, + "Heuristic URL is correct" + ); + Assert.equal(gURLBar.value, "example.com/", "Input has been autofilled"); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad(heuristicURL); + }); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK with an unknown URL should be +// hidden. +add_task(async function fallback_unknownURL() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search for an unknown URL and check the hidden heuristic result. + let url = "http://example.com/unknown-url"; + let heuristic = await search({ + value: url, + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK, + }); + Assert.equal(heuristic.payload.url, url, "Heuristic URL is correct"); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad(url); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK with the search restriction token +// should be hidden. +add_task(async function fallback_searchRestrictionToken() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Add a mock default engine so we don't hit the network. + await withEngine({ makeDefault: true }, async () => { + // Do a search with `?` and check the hidden heuristic result. + let heuristic = await search({ + value: UrlbarTokenizer.RESTRICT.SEARCH + " foo", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK, + }); + Assert.equal( + heuristic.payload.engine, + "Example", + "Heuristic engine is correct" + ); + Assert.equal( + heuristic.payload.query, + "foo", + "Heuristic query is correct" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "Example", + entry: "typed", + }); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad("https://example.com/?q=foo"); + + await UrlbarTestUtils.formHistory.clear(window); + }); + }); + }); +}); + +// UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK with a search string that falls +// back to a search result should be hidden. +add_task(async function fallback_search() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Add a mock default engine so we don't hit the network. + await withEngine({ makeDefault: true }, async () => { + // Do a search and check the hidden heuristic result. + let heuristic = await search({ + value: "foo", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK, + }); + Assert.equal( + heuristic.payload.engine, + "Example", + "Heuristic engine is correct" + ); + Assert.equal( + heuristic.payload.query, + "foo", + "Heuristic query is correct" + ); + + // Check the other visit results. + await checkVisitResults(visitURLs); + + // Press enter to verify the heuristic result is loaded. + await synthesizeEnterAndAwaitLoad("https://example.com/?q=foo"); + + await UrlbarTestUtils.formHistory.clear(window); + }); + }); + }); +}); + +// Picking a non-heuristic result should work correctly (and not pick the +// heuristic). +add_task(async function pickNonHeuristic() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await withVisits(async visitURLs => { + // Do a search that triggers autofill and check the hidden heuristic + // result. + let heuristic = await search({ + value: "ex", + expectedGroup: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL, + }); + Assert.ok(heuristic.autofill, "Heuristic is autofill"); + Assert.equal( + heuristic.payload.url, + "http://example.com/", + "Heuristic URL is correct" + ); + + // Pick the first visit result. + Assert.notEqual( + heuristic.payload.url, + visitURLs[0], + "Sanity check: Heuristic and first results have different URLs" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await synthesizeEnterAndAwaitLoad(visitURLs[0]); + }); + }); +}); + +/** + * Adds `maxRichResults` visits, calls your callback, and clears history. We add + * `maxRichResults` visits to verify that the view correctly contains the + * maximum number of results when the heuristic is hidden. + * + * @param {Function} callback + * The callback to call after adding visits. Can be async + */ +async function withVisits(callback) { + let urls = []; + for (let i = 0; i < UrlbarPrefs.get("maxRichResults"); i++) { + urls.push("http://example.com/foo/" + i); + } + await PlacesTestUtils.addVisits(urls); + + // The URLs will appear in the view in reverse order so that newer visits are + // first. Reverse the array now so callers to `checkVisitResults` or + // `checkVisitResults` itself doesn't need to do it. + urls.reverse(); + + await callback(urls); + await PlacesUtils.history.clear(); +} + +/** + * Adds a search engine, calls your callback, and removes the engine. + * + * @param {object} options + * Options object + * @param {string} [options.keyword] + * The keyword/alias for the engine. + * @param {boolean} [options.makeDefault] + * Whether to make the engine default. + * @param {Function} callback + * The callback to call after changing the default search engine. Can be async + */ +async function withEngine( + { keyword = undefined, makeDefault = false }, + callback +) { + await SearchTestUtils.installSearchExtension({ keyword }); + let engine = Services.search.getEngineByName("Example"); + let originalEngine; + if (makeDefault) { + originalEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + } + await callback(); + if (originalEngine) { + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + } + await Services.search.removeEngine(engine); +} + +/** + * Asserts the view contains visit results with the given URLs. + * + * @param {Array} expectedURLs + * The expected urls. + */ +async function checkVisitResults(expectedURLs) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedURLs.length, + "The view has other results" + ); + for (let i = 0; i < expectedURLs.length; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Other result type is correct at index " + i + ); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Other result source is correct at index " + i + ); + Assert.equal( + result.url, + expectedURLs[i], + "Other result URL is correct at index " + i + ); + } +} + +/** + * Performs a search and makes some basic assertions under the assumption that + * the heuristic should be hidden. + * + * @param {object} options + * Options object + * @param {string} options.value + * The search string. + * @param {UrlbarUtils.RESULT_GROUP} options.expectedGroup + * The expected result group of the hidden heuristic. + * @returns {UrlbarResult} + * The hidden heuristic result. + */ +async function search({ value, expectedGroup }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value, + fireInputEvent: true, + }); + + // _resultForCurrentValue should be the hidden heuristic result. + let { _resultForCurrentValue: result } = gURLBar; + Assert.ok(result, "_resultForCurrentValue is defined"); + Assert.ok(result.heuristic, "_resultForCurrentValue.heuristic is true"); + Assert.equal( + UrlbarUtils.getResultGroup(result), + expectedGroup, + "_resultForCurrentValue has expected group" + ); + + Assert.ok(!UrlbarTestUtils.getSelectedElement(window), "No selection exists"); + + return result; +} + +/** + * Synthesizes the enter key and waits for a load in the current tab. + * + * @param {string} expectedURL + * The URL that should load. + */ +async function synthesizeEnterAndAwaitLoad(expectedURL) { + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expectedURL + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + await PlacesUtils.history.clear(); +} diff --git a/browser/components/urlbar/tests/browser/browser_ime_composition.js b/browser/components/urlbar/tests/browser/browser_ime_composition.js new file mode 100644 index 0000000000..13a0cf0584 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_ime_composition.js @@ -0,0 +1,327 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests ime composition handling. + +function composeAndCheckPanel(string, isPopupOpen) { + EventUtils.synthesizeCompositionChange({ + composition: { + string, + clauses: [ + { + length: string.length, + attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE, + }, + ], + }, + caret: { start: string.length, length: 0 }, + key: { key: string ? string[string.length - 1] : "KEY_Backspace" }, + }); + Assert.equal( + UrlbarTestUtils.isPopupOpen(window), + isPopupOpen, + "Check panel open state" + ); +} + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + await PlacesUtils.history.clear(); + // Add at least one typed entry for the empty results set. Also clear history + // so that this can be over the autofill threshold. + await PlacesTestUtils.addVisits({ + uri: "http://mozilla.org/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }); + // Add a bookmark to ensure we autofill the engine domain for tab-to-search. + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com/", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + + await SearchTestUtils.installSearchExtension( + { + name: "Test", + keyword: "@test", + }, + { setAsDefault: true } + ); + + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.remove(bm); + await PlacesUtils.history.clear(); + }); + + // Test both pref values. + for (let val of [true, false]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.keepPanelOpenDuringImeComposition", val]], + }); + await test_composition(val); + await test_composition_searchMode_preview(val); + await test_composition_tabToSearch(val); + await test_composition_autofill(val); + } + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +async function test_composition(keepPanelOpenDuringImeComposition) { + gURLBar.focus(); + await UrlbarTestUtils.promisePopupClose(window); + + info("Check the panel state during composition"); + composeAndCheckPanel("I", false); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + composeAndCheckPanel("In", false); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + + info("Committing composition should open the panel."); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Enter" }, + }); + }); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + + info("Check the panel state starting from an open panel."); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + composeAndCheckPanel("t", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Int", "Check urlbar value"); + composeAndCheckPanel("te", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + + // Committing composition should open the popup. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Enter" }, + }); + }); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + + info("If composition is cancelled, the value shouldn't be changed."); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + composeAndCheckPanel("r", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Inter", "Check urlbar value"); + composeAndCheckPanel("", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + // Canceled compositionend should reopen the popup. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommit", + data: "", + key: { key: "KEY_Escape" }, + }); + }); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + + info( + "If composition replaces some characters and canceled, the search string should be the latest value." + ); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + EventUtils.synthesizeKey("VK_LEFT", { shiftKey: true }); + EventUtils.synthesizeKey("VK_LEFT", { shiftKey: true }); + composeAndCheckPanel("t", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Int", "Check urlbar value"); + composeAndCheckPanel("te", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + composeAndCheckPanel("", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + + // Canceled compositionend should search the result with the latest value. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Escape" }, + }); + }); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + info( + "Removing all characters should leave the popup open, Esc should then close it." + ); + EventUtils.synthesizeKey("KEY_Backspace", {}); + EventUtils.synthesizeKey("KEY_Backspace", {}); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape", {}); + }); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + + info("Composition which is canceled shouldn't cause opening the popup."); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Popup should be closed"); + composeAndCheckPanel("I", false); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + composeAndCheckPanel("In", false); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + composeAndCheckPanel("", false); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + + info("Canceled compositionend shouldn't open the popup if it was closed."); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Escape" }, + }); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Popup should be closed"); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + + info("Down key should open the popup even if the editor is empty."); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + }); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + + info( + "If popup is open at starting composition, the popup should be reopened after composition anyway." + ); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + composeAndCheckPanel("I", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + composeAndCheckPanel("In", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + composeAndCheckPanel("", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + // A canceled compositionend should open the popup if it was open. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Escape" }, + }); + }); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + + info("Type normally, and hit escape, the popup should be closed."); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open"); + EventUtils.synthesizeKey("I", {}); + EventUtils.synthesizeKey("n", {}); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape", {}); + }); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + // Clear typed chars. + EventUtils.synthesizeKey("KEY_Backspace", {}); + EventUtils.synthesizeKey("KEY_Backspace", {}); + Assert.equal(gURLBar.value, "", "Check urlbar value"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape", {}); + }); + + info("With autofill, compositionstart shouldn't open the popup"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Popup should be closed"); + composeAndCheckPanel("M", false); + Assert.equal(gURLBar.value, "M", "Check urlbar value"); + composeAndCheckPanel("Mo", false); + Assert.equal(gURLBar.value, "Mo", "Check urlbar value"); + // Committing composition should open the popup. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Enter" }, + }); + }); + Assert.equal(gURLBar.value, "Mozilla.org/", "Check urlbar value"); +} + +async function test_composition_searchMode_preview( + keepPanelOpenDuringImeComposition +) { + info("Check Search Mode preview is retained by composition"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + while (gURLBar.searchMode?.engineName != "Test") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + } + let expectedSearchMode = { + engineName: "Test", + isPreview: true, + entry: "keywordoffer", + }; + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + composeAndCheckPanel("I", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + if (keepPanelOpenDuringImeComposition) { + await UrlbarTestUtils.promiseSearchComplete(window); + } + // Test that we are in confirmed search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "Test", + entry: "keywordoffer", + }); + await UrlbarTestUtils.exitSearchMode(window); +} + +async function test_composition_tabToSearch(keepPanelOpenDuringImeComposition) { + info("Check Tab-to-Search is retained by composition"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + fireInputEvent: true, + }); + + while (gURLBar.searchMode?.engineName != "Test") { + EventUtils.synthesizeKey("KEY_Tab", {}, window); + } + let expectedSearchMode = { + engineName: "Test", + isPreview: true, + entry: "tabtosearch", + }; + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + composeAndCheckPanel("I", keepPanelOpenDuringImeComposition); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + if (keepPanelOpenDuringImeComposition) { + await UrlbarTestUtils.promiseSearchComplete(window); + } + // Test that we are in confirmed search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "Test", + entry: "tabtosearch", + }); + await UrlbarTestUtils.exitSearchMode(window); +} + +async function test_composition_autofill(keepPanelOpenDuringImeComposition) { + info("Check whether autofills or not"); + await UrlbarTestUtils.promisePopupClose(window); + + info("Check the urlbar value during composition"); + composeAndCheckPanel("m", false); + + if (keepPanelOpenDuringImeComposition) { + info("Wait for search suggestions"); + await UrlbarTestUtils.promiseSearchComplete(window); + } + + Assert.equal( + gURLBar.value, + "m", + "The urlbar value is not autofilled while turning IME on" + ); + + info("Check the urlbar value after committing composition"); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Enter" }, + }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gURLBar.value, "mozilla.org/", "The urlbar value is autofilled"); + + // Clean-up. + gURLBar.value = ""; +} diff --git a/browser/components/urlbar/tests/browser/browser_inputHistory.js b/browser/components/urlbar/tests/browser/browser_inputHistory.js new file mode 100644 index 0000000000..7fb93ca35d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_inputHistory.js @@ -0,0 +1,548 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests the urlbar adaptive behavior powered by input history. + */ + +"use strict"; + +async function bumpScore( + uri, + searchString, + counts, + useMouseClick = false, + needToLoad = false +) { + if (counts.visits) { + let visits = new Array(counts.visits).fill(uri); + await PlacesTestUtils.addVisits(visits); + } + if (counts.picks) { + for (let i = 0; i < counts.picks; ++i) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + let promise = needToLoad + ? BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser) + : BrowserTestUtils.waitForDocLoadAndStopIt( + uri, + gBrowser.selectedBrowser + ); + // Look for the expected uri. + while (gURLBar.untrimmedValue != uri) { + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + } + if (useMouseClick) { + let element = UrlbarTestUtils.getSelectedRow(window); + EventUtils.synthesizeMouseAtCenter(element, {}); + } else { + EventUtils.synthesizeKey("KEY_Enter", {}); + } + await promise; + } + } + await PlacesTestUtils.promiseAsyncUpdates(); +} + +async function decayInputHistory() { + await Cc["@mozilla.org/places/frecency-recalculator;1"] + .getService(Ci.nsIObserver) + .wrappedJSObject.decay(); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // We don't want autofill to influence this test. + ["browser.urlbar.autoFill", false], + ], + }); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function test_adaptive_with_search_terms() { + let url1 = "http://site.tld/1"; + let url2 = "http://site.tld/2"; + + info("Same visit count, same picks, one partial match, one exact match"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await bumpScore(url2, "site", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); + + info( + "Same visit count, same picks, one partial match, one exact match, invert" + ); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 3 }); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); + + info("Same visit count, different picks, both exact match"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await bumpScore(url2, "si", { visits: 3, picks: 1 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); + + info("Same visit count, different picks, both exact match, invert"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "si", { visits: 3, picks: 1 }); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); + + info("Same visit count, different picks, both partial match"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 3 }); + await bumpScore(url2, "site", { visits: 3, picks: 1 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); + + info("Same visit count, different picks, both partial match, invert"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 1 }); + await bumpScore(url2, "site", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); +}); + +add_task(async function test_adaptive_with_decay() { + let url1 = "http://site.tld/1"; + let url2 = "http://site.tld/2"; + + info("Same visit count, same picks, both exact match, decay first"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await decayInputHistory(); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); + + info("Same visit count, same picks, both exact match, decay second"); + await PlacesUtils.history.clear(); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await decayInputHistory(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); +}); + +add_task(async function test_adaptive_limited() { + let url1 = "http://site.tld/1"; + let url2 = "http://site.tld/2"; + + info("Same visit count, same picks, both exact match, decay first"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await decayInputHistory(); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); + + info("Same visit count, same picks, both exact match, decay second"); + await PlacesUtils.history.clear(); + await bumpScore(url2, "si", { visits: 3, picks: 3 }); + await decayInputHistory(); + await bumpScore(url1, "si", { visits: 3, picks: 3 }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); +}); + +add_task(async function test_adaptive_limited() { + info("Up to 3 adaptive results should be added at the top, then enqueued"); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add as many adaptive results as maxRichResults. + let n = UrlbarPrefs.get("maxRichResults"); + let urls = Array(n) + .fill(0) + .map((e, i) => "http://site.tld/" + i); + for (let url of urls) { + await bumpScore(url, "site", { visits: 1, picks: 1 }); + } + + // Add a matching bookmark with an higher frecency. + let url = "http://site.bookmark.tld/"; + await PlacesTestUtils.addVisits(url); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test_site_book", + url, + }); + + // After 1 heuristic and 3 input history results. + let expectedBookmarkIndex = 4; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "site", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + expectedBookmarkIndex + ); + Assert.equal(result.url, url, "Check bookmarked result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, n - 1); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + n, + "Check all the results are filled" + ); + Assert.ok( + result.url.startsWith("http://site.tld"), + "Check last adaptive result" + ); + + await PlacesUtils.bookmarks.remove(bm); +}); + +add_task(async function test_adaptive_behaviors() { + info( + "Check adaptive results are not provided regardless of the requested behavior" + ); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add an adaptive entry. + let historyUrl = "http://site.tld/1"; + await bumpScore(historyUrl, "site", { visits: 1, picks: 1 }); + + let bookmarkURL = "http://bookmarked.site.tld/1"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test_book", + url: bookmarkURL, + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Search only bookmarks. + ["browser.urlbar.suggest.bookmark", true], + ["browser.urlbar.suggest.history", false], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "site", + }); + let result = (await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1)) + .result; + Assert.equal(result.payload.url, bookmarkURL, "Check bookmarked result"); + Assert.notEqual( + result.providerName, + "InputHistory", + "The bookmarked result is not from InputHistory." + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check there are no unexpected results" + ); + await PlacesUtils.bookmarks.remove(bm); + + // Repeat the previous case but now the bookmark has the same URL as the + // history result. We expect the returned result comes from InputHistory. + bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test_book", + url: historyUrl, + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sit", + }); + result = (await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1)) + .result; + Assert.equal(result.payload.url, historyUrl, "Check bookmarked result"); + Assert.equal( + result.providerName, + "InputHistory", + "The bookmarked result is from InputHistory." + ); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The input history result is a bookmark." + ); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check there are no unexpected results" + ); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.pushPrefEnv({ + set: [ + // Search only open pages. We don't provide an open page, but we want to + // enable at least one of these prefs so that UrlbarProviderInputHistory + // is active. + ["browser.urlbar.suggest.bookmark", false], + ["browser.urlbar.suggest.history", false], + ["browser.urlbar.suggest.openpage", true], + ], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "site", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There is no adaptive history result because it is not an open page." + ); + await SpecialPowers.popPrefEnv(); + + // Clearing history but not deleting the bookmark. This simulates the case + // where the user has cleared their history or is using permanent private + // browsing mode. + await PlacesUtils.history.clear(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.bookmark", true], + ["browser.urlbar.suggest.history", false], + ["browser.urlbar.suggest.openpage", false], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sit", + }); + result = (await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1)) + .result; + Assert.equal(result.payload.url, historyUrl, "Check bookmarked result"); + Assert.equal( + result.providerName, + "InputHistory", + "The bookmarked result is from InputHistory." + ); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The input history result is a bookmark." + ); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check there are no unexpected results" + ); + + await PlacesUtils.bookmarks.remove(bm); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_adaptive_mouse() { + info("Check adaptive results are updated on mouse picks"); + let url1 = "http://site.tld/1"; + let url2 = "http://site.tld/2"; + + info("Same visit count, different picks"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 3 }, true); + await bumpScore(url2, "site", { visits: 3, picks: 1 }, true); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); + + info("Same visit count, different picks, invert"); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 1 }, true); + await bumpScore(url2, "site", { visits: 3, picks: 3 }, true); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url2, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url1, "Check second result"); +}); + +add_task(async function test_adaptive_searchmode() { + info("Check adaptive history is not shown in search mode."); + + let suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + }); + + let url1 = "http://site.tld/1"; + let url2 = "http://site.tld/2"; + + info("Sanity check: adaptive history is shown for a normal search."); + await PlacesUtils.history.clear(); + await bumpScore(url1, "site", { visits: 3, picks: 3 }, true); + await bumpScore(url2, "site", { visits: 3, picks: 1 }, true); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "si", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url1, "Check first result"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal(result.url, url2, "Check second result"); + + info("Entering search mode."); + // enterSearchMode checks internally that our site.tld URLs are not shown. + await UrlbarTestUtils.enterSearchMode(window, { + engineName: suggestionsEngine.name, + }); + + await Services.search.removeEngine(suggestionsEngine); +}); + +add_task(async function test_ignore_case() { + const url1 = "http://example.com/yes"; + const url2 = "http://example.com/no"; + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([url1, url2]); + await UrlbarUtils.addToInputHistory(url1, "SampLE"); + await UrlbarUtils.addToInputHistory(url1, "SaMpLE"); + await UrlbarUtils.addToInputHistory(url1, "SAMPLE"); + await UrlbarUtils.addToInputHistory(url1, "sample"); + await UrlbarUtils.addToInputHistory(url2, "sample"); + await UrlbarUtils.addToInputHistory(url2, "sample"); + await UrlbarUtils.addToInputHistory(url2, "sample"); + await UrlbarUtils.addToInputHistory(url2, "sample"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sAM", + }); + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.url, + url1, + "Seaching for input history is case-insensitive" + ); +}); + +add_task(async function test_adaptive_history_in_privatewindow() { + info( + "Check adaptive history is not shown in private window as tab switching candidate." + ); + + await PlacesUtils.history.clear(); + + info("Add a test url as an input history."); + const url = "http://example.com/"; + // We need to wait for loading the page in order to register the url into + // moz_openpages_temp table. + await bumpScore(url, "exa", { visits: 1, picks: 1 }, false, true); + + info("Check the url could be registered properly."); + const connection = await PlacesUtils.promiseLargeCacheDBConnection(); + const rows = await connection.executeCached( + "SELECT userContextId FROM moz_openpages_temp WHERE url = :url", + { url } + ); + Assert.equal(rows.length, 1, "Length of rows for the url is 1."); + Assert.greaterOrEqual( + rows[0].getResultByName("userContextId"), + 0, + "The url is registered as non-private-browsing context." + ); + + info("Open popup in private window."); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWindow, + value: "ex", + }); + + info("Check the popup results."); + let hasResult = false; + for (let i = 0; i < UrlbarTestUtils.getResultCount(privateWindow); i++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(privateWindow, i); + + if (result.url !== url) { + continue; + } + + Assert.notEqual( + result.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Result type of the url is not for tab switch." + ); + + hasResult = true; + } + Assert.ok(hasResult, "Popup has a result for the url."); + + await BrowserTestUtils.closeWindow(privateWindow); +}); diff --git a/browser/components/urlbar/tests/browser/browser_inputHistory_autofill.js b/browser/components/urlbar/tests/browser/browser_inputHistory_autofill.js new file mode 100644 index 0000000000..19457884b6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_inputHistory_autofill.js @@ -0,0 +1,207 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests for input history related to autofill. + +"use strict"; + +let addToInputHistorySpy; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill.adaptiveHistory.enabled", true]], + }); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + let sandbox = sinon.createSandbox(); + addToInputHistorySpy = sandbox.spy(UrlbarUtils, "addToInputHistory"); + + registerCleanupFunction(async () => { + sandbox.restore(); + }); +}); + +// Input history use count should be bumped when an adaptive history autofill +// result is triggered and picked. +add_task(async function bumped() { + let input = "exam"; + let tests = [ + // Basic test where the search string = the adaptive history input. + { + url: "http://example.com/test", + searchString: "exam", + }, + // The history with input "exam" should be bumped, not "example", even + // though the search string is "example". + { + url: "http://example.com/test", + searchString: "example", + }, + // The history with URL "http://www.example.com/test" should be bumped, not + // "http://example.com/test", even though the search string starts with + // "example". + { + url: "http://www.example.com/test", + searchString: "exam", + }, + ]; + + for (let { url, searchString } of tests) { + info("Running subtest: " + JSON.stringify({ url, searchString })); + + await PlacesTestUtils.addVisits(url); + await UrlbarUtils.addToInputHistory(url, input); + addToInputHistorySpy.resetHistory(); + + let initialUseCount = await getUseCount({ url, input }); + info("Got initial use count: " + initialUseCount); + + await triggerAutofillAndPickResult(searchString, "example.com/test"); + + let calls = addToInputHistorySpy.getCalls(); + Assert.equal( + calls.length, + 1, + "UrlbarUtils.addToInputHistory() called once" + ); + Assert.deepEqual( + calls[0].args, + [url, input], + "UrlbarUtils.addToInputHistory() called with expected args" + ); + + Assert.greater( + await getUseCount({ url, input }), + initialUseCount, + "New use count > initial use count" + ); + + if (searchString != input) { + Assert.strictEqual( + await getUseCount({ input: searchString }), + undefined, + "Search string not present in input history: " + searchString + ); + } + + await PlacesUtils.history.clear(); + await PlacesTestUtils.clearInputHistory(); + addToInputHistorySpy.resetHistory(); + } +}); + +// Input history use count should not be bumped when an origin autofill result +// is triggered and picked. +add_task(async function notBumped_origin() { + // Add enough visits to trigger origin autofill. + let url = "http://example.com/test"; + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + + await triggerAutofillAndPickResult("exam", "example.com/"); + + let calls = addToInputHistorySpy.getCalls(); + Assert.equal(calls.length, 0, "UrlbarUtils.addToInputHistory() not called"); + + Assert.strictEqual( + await getUseCount({ url }), + undefined, + "URL not present in input history: " + url + ); + + await PlacesUtils.history.clear(); +}); + +// Input history use count should not be bumped when a URL autofill result is +// triggered and picked. +add_task(async function notBumped_url() { + let url = "http://example.com/test"; + await PlacesTestUtils.addVisits(url); + + await triggerAutofillAndPickResult("example.com/t", "example.com/test"); + + let calls = addToInputHistorySpy.getCalls(); + Assert.equal(calls.length, 0, "UrlbarUtils.addToInputHistory() not called"); + + Assert.strictEqual( + await getUseCount({ url }), + undefined, + "URL not present in input history: " + url + ); + + await PlacesUtils.history.clear(); +}); + +/** + * Performs a search and picks the first result. + * + * @param {string} searchString + * The search string. Assumed to trigger an autofill result. + * @param {string} autofilledValue + * The input's expected value after autofill occurs. + */ +async function triggerAutofillAndPickResult(searchString, autofilledValue) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "Result is autofill"); + Assert.equal(gURLBar.value, autofilledValue, "gURLBar.value"); + Assert.equal(gURLBar.selectionStart, searchString.length, "selectionStart"); + Assert.equal(gURLBar.selectionEnd, autofilledValue.length, "selectionEnd"); + + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); +} + +/** + * Gets the use count of an input history record. + * + * @param {object} options + * Options object. + * @param {string} [options.url] + * The URL of the `moz_places` row corresponding to the record. + * @param {string} [options.input] + * The `input` value in the record. + * @returns {number} + * The use count. If no record exists with the URL and/or input, undefined is + * returned. + */ +async function getUseCount({ url = undefined, input = undefined }) { + return PlacesUtils.withConnectionWrapper("test::getUseCount", async db => { + let rows; + if (input && url) { + rows = await db.executeCached( + `SELECT i.use_count + FROM moz_inputhistory i + JOIN moz_places h ON h.id = i.place_id + WHERE h.url = :url AND i.input = :input`, + { url, input } + ); + } else if (url) { + rows = await db.executeCached( + `SELECT i.use_count + FROM moz_inputhistory i + JOIN moz_places h ON h.id = i.place_id + WHERE h.url = :url`, + { url } + ); + } else if (input) { + rows = await db.executeCached( + `SELECT use_count + FROM moz_inputhistory i + WHERE input = :input`, + { input } + ); + } + return rows[0]?.getResultByIndex(0); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_inputHistory_emptystring.js b/browser/components/urlbar/tests/browser/browser_inputHistory_emptystring.js new file mode 100644 index 0000000000..28c967a851 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_inputHistory_emptystring.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests input history in cases where the search string is empty. + * In the future we may want to not account for these, but for now they are + * stored with an empty input field. + */ + +"use strict"; + +async function checkInputHistory(len = 0) { + await PlacesUtils.withConnectionWrapper( + "test::checkInputHistory", + async db => { + let rows = await db.executeCached(`SELECT input FROM moz_inputhistory`); + Assert.equal(rows.length, len, "There should only be 1 entry"); + if (len) { + Assert.equal(rows[0].getResultByIndex(0), "", "Input should be empty"); + } + } + ); +} + +const TEST_URL = "http://example.com/"; + +async function do_test(openFn, pickMethod) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + async function (browser) { + await PlacesTestUtils.clearInputHistory(); + await openFn(); + await UrlbarTestUtils.promiseSearchComplete(window); + let promise = BrowserTestUtils.waitForDocLoadAndStopIt(TEST_URL, browser); + if (pickMethod == "keyboard") { + info(`Test pressing Enter`); + EventUtils.sendKey("down"); + EventUtils.sendKey("return"); + } else { + info("Test with click"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + } + await promise; + await checkInputHistory(1); + } + ); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(TEST_URL); + } + + await updateTopSites(sites => sites && sites[0] && sites[0].url == TEST_URL); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_history_no_search_terms() { + for (let pickMethod of ["keyboard", "mouse"]) { + // If a testFn returns false, it will be skipped. + for (let openFn of [ + () => { + info("Test opening panel with down key"); + gURLBar.focus(); + EventUtils.sendKey("down"); + }, + async () => { + info("Test opening panel on focus"); + gURLBar.blur(); + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox, {}); + }, + async () => { + info("Test opening panel on focus on a page"); + let selectedBrowser = gBrowser.selectedBrowser; + // A page other than TEST_URL must be loaded, or the first Top Site + // result will be a switch-to-tab result and page won't be reloaded when + // the result is selected. + BrowserTestUtils.loadURIString(selectedBrowser, "http://example.org/"); + await BrowserTestUtils.browserLoaded(selectedBrowser); + gURLBar.blur(); + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox, {}); + }, + ]) { + await do_test(openFn, pickMethod); + } + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js b/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js new file mode 100644 index 0000000000..03ba6a6473 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js @@ -0,0 +1,224 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Verify user typed text remains in the URL bar when tab switching, even when + * loads fail. + */ +add_task(async function validURL() { + let input = "i-definitely-dont-exist.example.com"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let browser = tab.linkedBrowser; + // Note: Waiting on content document not being hidden because new tab pages can be preloaded, + // in which case no load events fire. + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && !content.document.hidden; + }); + }); + let errorPageLoaded = BrowserTestUtils.waitForErrorPage(browser); + gURLBar.value = input; + gURLBar.select(); + EventUtils.sendKey("return"); + await errorPageLoaded; + is(gURLBar.value, input, "Text is still in URL bar"); + await BrowserTestUtils.switchTab(gBrowser, tab.previousElementSibling); + await BrowserTestUtils.switchTab(gBrowser, tab); + is(gURLBar.value, input, "Text is still in URL bar after tab switch"); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Invalid URIs fail differently (that is, immediately, in the loadURI call) + * if keyword searches are turned off. Test that this works, too. + */ +add_task(async function invalidURL() { + let input = "To be or not to be-that is the question"; + await SpecialPowers.pushPrefEnv({ set: [["keyword.enabled", false]] }); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank", + false + ); + let browser = tab.linkedBrowser; + // Note: Waiting on content document not being hidden because new tab pages can be preloaded, + // in which case no load events fire. + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && !content.document.hidden; + }); + }); + let errorPageLoaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser); + gURLBar.value = input; + gURLBar.select(); + EventUtils.sendKey("return"); + await errorPageLoaded; + is(gURLBar.value, input, "Text is still in URL bar"); + is(tab.linkedBrowser.userTypedValue, input, "Text still stored on browser"); + await BrowserTestUtils.switchTab(gBrowser, tab.previousElementSibling); + await BrowserTestUtils.switchTab(gBrowser, tab); + is(gURLBar.value, input, "Text is still in URL bar after tab switch"); + is(tab.linkedBrowser.userTypedValue, input, "Text still stored on browser"); + BrowserTestUtils.removeTab(tab); +}); + +/** + * Test the urlbar status of text selection and focusing by tab switching. + */ +add_task(async function selectAndFocus() { + // Create a tab with normal web page. + const webpageTabURL = "https://example.com"; + const webpageTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: webpageTabURL, + }); + + // Create a tab with userTypedValue. + const userTypedTabText = "test"; + const userTypedTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + }); + await UrlbarTestUtils.inputIntoURLBar(window, userTypedTabText); + + // Create an empty tab. + const emptyTab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(webpageTab); + BrowserTestUtils.removeTab(userTypedTab); + BrowserTestUtils.removeTab(emptyTab); + }); + + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 2, + targetSelectionEnd: 5, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: webpageTabURL.length, + targetSelectionEnd: webpageTabURL.length, + anotherTab: userTypedTab, + }); + await doSelectAndFocusTest({ + targetTab: webpageTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: webpageTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: 1, + targetSelectionEnd: 2, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: userTypedTab, + targetSelectionStart: userTypedTabText.length, + targetSelectionEnd: userTypedTabText.length, + anotherTab: emptyTab, + }); + await doSelectAndFocusTest({ + targetTab: emptyTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: webpageTab, + }); + await doSelectAndFocusTest({ + targetTab: emptyTab, + targetSelectionStart: 0, + targetSelectionEnd: 0, + anotherTab: userTypedTab, + }); +}); + +async function doSelectAndFocusTest({ + targetTab, + targetSelectionStart, + targetSelectionEnd, + anotherTab, +}) { + const testCases = [ + { + targetFocus: false, + anotherFocus: false, + }, + { + targetFocus: true, + anotherFocus: false, + }, + { + targetFocus: true, + anotherFocus: true, + }, + ]; + + for (const { targetFocus, anotherFocus } of testCases) { + // Setup the target tab. + await switchTab(targetTab); + setURLBarFocus(targetFocus); + gURLBar.inputField.setSelectionRange( + targetSelectionStart, + targetSelectionEnd + ); + const targetValue = gURLBar.value; + + // Switch to another tab. + await switchTab(anotherTab); + setURLBarFocus(anotherFocus); + + // Switch back to the target tab. + await switchTab(targetTab); + + // Check whether the value, selection and focusing status are reverted. + Assert.equal(gURLBar.value, targetValue); + Assert.equal(gURLBar.focused, targetFocus); + if (gURLBar.focused) { + Assert.equal(gURLBar.selectionStart, targetSelectionStart); + Assert.equal(gURLBar.selectionEnd, targetSelectionEnd); + } else { + Assert.equal(gURLBar.selectionStart, gURLBar.value.length); + Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); + } + } +} + +function setURLBarFocus(focus) { + if (focus) { + gURLBar.focus(); + } else { + gURLBar.blur(); + } +} + +async function switchTab(tab) { + if (gBrowser.selectedTab !== tab) { + EventUtils.synthesizeMouseAtCenter(tab, {}); + await BrowserTestUtils.waitForCondition(() => gBrowser.selectedTab === tab); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_keyword.js b/browser/components/urlbar/tests/browser/browser_keyword.js new file mode 100644 index 0000000000..618bcad3c7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keyword.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This tests that keywords are displayed and handled correctly. + */ + +async function promise_first_result(inputText) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: inputText, + }); + + return UrlbarTestUtils.getDetailsOfResultAt(window, 0); +} + +function assertURL(result, expectedUrl, keyword, input, postData) { + Assert.equal(result.url, expectedUrl, "Should have the correct URL"); + if (postData) { + Assert.equal( + NetUtil.readInputStreamToString( + result.postData, + result.postData.available() + ), + postData, + "Should have the correct postData" + ); + } +} + +const TEST_URL = `${TEST_BASE_URL}print_postdata.sjs`; + +add_setup(async function () { + await PlacesUtils.keywords.insert({ + keyword: "get", + url: TEST_URL + "?q=%s", + }); + await PlacesUtils.keywords.insert({ + keyword: "post", + url: TEST_URL, + postData: "q=%s", + }); + await PlacesUtils.keywords.insert({ + keyword: "question?", + url: TEST_URL + "?q2=%s", + }); + await PlacesUtils.keywords.insert({ + keyword: "?question", + url: TEST_URL + "?q3=%s", + }); + // Avoid fetching search suggestions. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("get"); + await PlacesUtils.keywords.remove("post"); + await PlacesUtils.keywords.remove("question?"); + await PlacesUtils.keywords.remove("?question"); + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + }); +}); + +add_task(async function test_display_keyword_without_query() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + + // Test a keyword that also has blank spaces to ensure they are ignored as well. + let result = await promise_first_result("get "); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + Assert.equal( + result.displayed.title, + "https://example.com/browser/browser/components/urlbar/tests/browser/print_postdata.sjs?q=", + "Node should contain the url of the bookmark" + ); + let [action] = await document.l10n.formatValues([ + { id: "urlbar-result-action-visit" }, + ]); + Assert.equal(result.displayed.action, action, "Should have visit indicated"); +}); + +add_task(async function test_keyword_using_get() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + let result = await promise_first_result("get something"); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + Assert.equal( + result.displayed.title, + "example.com: something", + "Node should contain the name of the bookmark and query" + ); + Assert.ok(!result.displayed.action, "Should have an empty action"); + + assertURL(result, TEST_URL + "?q=something", "get", "get something"); + + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + + // Click on the result + info("Normal click on result"); + let tabPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeMouseAtCenter(element, {}); + await tabPromise; + Assert.equal( + tab.linkedBrowser.currentURI.spec, + TEST_URL + "?q=something", + "Tab should have loaded from clicking on result" + ); + + // Middle-click on the result + info("Middle-click on result"); + result = await promise_first_result("get somethingmore"); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + + assertURL(result, TEST_URL + "?q=somethingmore", "get", "get somethingmore"); + + tabPromise = BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "TabOpen"); + element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, { button: 1 }); + let tabOpenEvent = await tabPromise; + let newTab = tabOpenEvent.target; + await BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + Assert.equal( + newTab.linkedBrowser.currentURI.spec, + TEST_URL + "?q=somethingmore", + "Tab should have loaded from middle-clicking on result" + ); +}); + +add_task(async function test_keyword_using_post() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + let result = await promise_first_result("post something"); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + Assert.equal( + result.displayed.title, + "example.com: something", + "Node should contain the name of the bookmark and query" + ); + Assert.ok(!result.displayed.action, "Should have an empty action"); + + assertURL(result, TEST_URL, "post", "post something", "q=something"); + + // Click on the result + info("Normal click on result"); + let tabPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, {}); + info("waiting for tab"); + await tabPromise; + Assert.equal( + tab.linkedBrowser.currentURI.spec, + TEST_URL, + "Tab should have loaded from clicking on result" + ); + + let postData = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + async function () { + return content.document.body.textContent; + } + ); + Assert.equal(postData, "q=something", "post data was submitted correctly"); +}); + +add_task(async function test_keyword_with_question_mark() { + // TODO Bug 1517140: keywords containing restriction chars should not be + // allowed, or properly supported. + let result = await promise_first_result("question?"); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result should be a search" + ); + Assert.equal(result.searchParams.query, "question?", "Check search query"); + + result = await promise_first_result("question? something"); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Result should be a keyword" + ); + Assert.equal(result.keyword, "question?", "Check search query"); + + result = await promise_first_result("?question"); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result should be a search" + ); + Assert.equal(result.searchParams.query, "question", "Check search query"); + + result = await promise_first_result("?question something"); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result should be a search" + ); + Assert.equal( + result.searchParams.query, + "question something", + "Check search query" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js b/browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js new file mode 100644 index 0000000000..c10fcdd9c3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keywordBookmarklets.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmarklet", + url: "javascript:'%sx'%20", + }); + await PlacesUtils.keywords.insert({ keyword: "bm", url: bm.url }); + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + let testFns = [ + function () { + info("Type keyword and immediately press enter"); + gURLBar.value = "bm"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + return "x"; + }, + function () { + info("Type keyword with searchstring and immediately press enter"); + gURLBar.value = "bm a"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + return "ax"; + }, + async function () { + info("Search keyword, then press enter"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bm", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.title, "javascript:'x' ", "Check title"); + EventUtils.synthesizeKey("KEY_Enter"); + return "x"; + }, + async function () { + info("Search keyword with searchstring, then press enter"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bm a", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.title, "javascript:'ax' ", "Check title"); + EventUtils.synthesizeKey("KEY_Enter"); + return "ax"; + }, + async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bm", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.title, "javascript:'x' ", "Check title"); + let element = UrlbarTestUtils.getSelectedRow(window); + EventUtils.synthesizeMouseAtCenter(element, {}); + return "x"; + }, + async function () { + info("Search keyword with searchstring, then click"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bm a", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.title, "javascript:'ax' ", "Check title"); + let element = UrlbarTestUtils.getSelectedRow(window); + EventUtils.synthesizeMouseAtCenter(element, {}); + return "ax"; + }, + ]; + for (let testFn of testFns) { + await do_test(testFn); + } +}); + +async function do_test(loadFn) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + }, + async browser => { + let originalPrincipal = gBrowser.contentPrincipal; + let originalPrincipalURI = await getPrincipalURI(browser); + + let promise = BrowserTestUtils.waitForContentEvent(browser, "pageshow"); + const expectedTextContent = await loadFn(); + info("Awaiting pageshow event"); + await promise; + // URI should not change when we run a javascript: URL. + Assert.equal(gBrowser.currentURI.spec, "about:blank"); + const textContent = await ContentTask.spawn(browser, [], function () { + return content.document.documentElement.textContent; + }); + Assert.equal(textContent, expectedTextContent); + + let newPrincipalURI = await getPrincipalURI(browser); + Assert.equal( + newPrincipalURI, + originalPrincipalURI, + "content has the same principal" + ); + + // In e10s, null principals don't round-trip so the same null principal sent + // from the child will be a new null principal. Verify that this is the + // case. + if (browser.isRemoteBrowser) { + Assert.ok( + originalPrincipal.isNullPrincipal && + gBrowser.contentPrincipal.isNullPrincipal, + "both principals should be null principals in the parent" + ); + } else { + Assert.ok( + gBrowser.contentPrincipal.equals(originalPrincipal), + "javascript bookmarklet should inherit principal" + ); + } + } + ); +} + +function getPrincipalURI(browser) { + return SpecialPowers.spawn(browser, [], function () { + return content.document.nodePrincipal.spec; + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_keywordSearch.js b/browser/components/urlbar/tests/browser/browser_keywordSearch.js new file mode 100644 index 0000000000..b8402a4e90 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keywordSearch.js @@ -0,0 +1,57 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +var gTests = [ + { + name: "normal search (search service)", + text: "test search", + expectText: "test+search", + }, + { + name: "?-prefixed search (search service)", + text: "? foo ", + expectText: "foo", + }, +]; + +add_setup(async function () { + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); +}); + +add_task(async function () { + // Test both directly setting a value and pressing enter, or setting the + // value through input events, like the user would do. + const setValueFns = [ + value => { + gURLBar.value = value; + }, + value => { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + }, + ]; + + for (let test of gTests) { + info("Testing: " + test.name); + await BrowserTestUtils.withNewTab({ gBrowser }, async browser => { + for (let setValueFn of setValueFns) { + gURLBar.select(); + await setValueFn(test.text); + EventUtils.synthesizeKey("KEY_Enter"); + + let expectedUrl = "http://mochi.test:8888/?terms=" + test.expectText; + info("Waiting for load: " + expectedUrl); + await BrowserTestUtils.browserLoaded(browser, false, expectedUrl); + // At least one test. + Assert.equal(browser.currentURI.spec, expectedUrl); + } + }); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_keywordSearch_postData.js b/browser/components/urlbar/tests/browser/browser_keywordSearch_postData.js new file mode 100644 index 0000000000..d2b3aa253a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keywordSearch_postData.js @@ -0,0 +1,74 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +var gTests = [ + { + name: "single word search (search service)", + text: "pizza", + expectText: "pizza", + }, + { + name: "multi word search (search service)", + text: "test search", + expectText: "test+search", + }, + { + name: "?-prefixed search (search service)", + text: "? foo ", + expectText: "foo", + }, +]; + +add_setup(async function () { + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "POSTSearchEngine.xml", + setAsDefault: true, + }); +}); + +add_task(async function () { + // Test both directly setting a value and pressing enter, or setting the + // value through input events, like the user would do. + const setValueFns = [ + value => { + gURLBar.value = value; + }, + value => { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + }, + ]; + + for (let test of gTests) { + info("Testing: " + test.name); + await BrowserTestUtils.withNewTab({ gBrowser }, async browser => { + for (let setValueFn of setValueFns) { + gURLBar.select(); + await setValueFn(test.text); + EventUtils.synthesizeKey("KEY_Enter"); + + await BrowserTestUtils.browserLoaded( + browser, + false, + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs" + ); + + let textContent = await SpecialPowers.spawn(browser, [], async () => { + return content.document.body.textContent; + }); + + Assert.ok(textContent, "search page loaded"); + let needle = "searchterms=" + test.expectText; + Assert.equal( + textContent, + needle, + "The query POST data should be returned in the response" + ); + } + }); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_keyword_override.js b/browser/components/urlbar/tests/browser/browser_keyword_override.js new file mode 100644 index 0000000000..b358f3a4ac --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keyword_override.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This tests that the display of keyword results are not changed when the user + * presses the override button. + */ + +add_task(async function () { + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/?q=%s", + title: "test", + }); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/?q=%s", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.remove(bm); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "keyword search", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + info("Before override"); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + Assert.equal( + result.displayed.title, + "example.com: search", + "Node should contain the name of the bookmark and query" + ); + Assert.ok(!result.displayed.action, "Should have an empty action"); + + info("During override"); + EventUtils.synthesizeKey("VK_SHIFT", { type: "keydown" }); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a keyword result" + ); + Assert.equal( + result.displayed.title, + "example.com: search", + "Node should contain the name of the bookmark and query" + ); + Assert.ok(!result.displayed.action, "Should have an empty action"); + + EventUtils.synthesizeKey("VK_SHIFT", { type: "keyup" }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_keyword_select_and_type.js b/browser/components/urlbar/tests/browser/browser_keyword_select_and_type.js new file mode 100644 index 0000000000..a3222c293f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keyword_select_and_type.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This tests that changing away from a keyword result and back again, still + * operates correctly. + */ + +add_task(async function () { + let bookmarks = []; + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/?q=%s", + title: "test", + }) + ); + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "http://example.com/?q=%s", + }); + + // This item is only needed so we can select the keyword item, select something + // else, then select the keyword item again. + bookmarks.push( + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/keyword", + title: "keyword abc", + }) + ); + + registerCleanupFunction(async function () { + for (let bm of bookmarks) { + await PlacesUtils.bookmarks.remove(bm); + } + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "keyword a", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + + // First item should already be selected + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "Should have the first item selected" + ); + + // Select next one (important!) + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Should have the second item selected" + ); + + // Re-select keyword item + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "Should have the first item selected" + ); + + EventUtils.sendString("b"); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + gURLBar.value, + "keyword ab", + "urlbar should have expected input" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.KEYWORD, + "Should have a result of type keyword" + ); + Assert.equal( + result.url, + "http://example.com/?q=ab", + "Should have the correct url" + ); + + gBrowser.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_loadRace.js b/browser/components/urlbar/tests/browser/browser_loadRace.js new file mode 100644 index 0000000000..b257625f30 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_loadRace.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +// This test is for testing races of loading the Urlbar when loading shortcuts. +// For example, ensuring that if a search query is entered, but something causes +// a page load whilst we're getting the search url, then we don't handle the +// original search query. + +add_setup(async function () { + sandbox = sinon.createSandbox(); + + registerCleanupFunction(async () => { + sandbox.restore(); + }); +}); + +async function checkShortcutLoading(modifierKeys) { + let deferred = PromiseUtils.defer(); + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:robots", + }); + + // We stub getHeuristicResultFor to guarentee it doesn't resolve until after + // we've loaded a new page. + let original = UrlbarUtils.getHeuristicResultFor; + sandbox + .stub(UrlbarUtils, "getHeuristicResultFor") + .callsFake(async searchString => { + await deferred.promise; + return original.call(this, searchString); + }); + + // This load will be blocked until the deferred is resolved. + // Use a string that will be interepreted as a local URL to avoid hitting the + // network. + gURLBar.focus(); + gURLBar.value = "example.com"; + gURLBar.userTypedValue = true; + EventUtils.synthesizeKey("KEY_Enter", modifierKeys); + + Assert.ok( + UrlbarUtils.getHeuristicResultFor.calledOnce, + "should have called getHeuristicResultFor" + ); + + // Now load a different page. + BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:license"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + Assert.equal(gBrowser.visibleTabs.length, 2, "Should have 2 tabs"); + + // Now that the new page has loaded, unblock the previous urlbar load. + deferred.resolve(); + if (modifierKeys) { + let openedTab = await new Promise(resolve => { + window.addEventListener( + "TabOpen", + event => { + resolve(event.target); + }, + { once: true } + ); + }); + await BrowserTestUtils.browserLoaded(openedTab.linkedBrowser); + Assert.ok( + openedTab.linkedBrowser.currentURI.spec.includes("example.com"), + "Should have attempted to open the shortcut page" + ); + BrowserTestUtils.removeTab(openedTab); + } + + Assert.equal( + tab.linkedBrowser.currentURI.spec, + "about:license", + "Tab url should not have changed" + ); + Assert.equal(gBrowser.visibleTabs.length, 2, "Should still have 2 tabs"); + + BrowserTestUtils.removeTab(tab); + sandbox.restore(); +} + +add_task(async function test_location_change_stops_load() { + await checkShortcutLoading(); +}); + +add_task(async function test_opening_different_tab_with_location_change() { + await checkShortcutLoading({ altKey: true }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_locationBarCommand.js b/browser/components/urlbar/tests/browser/browser_locationBarCommand.js new file mode 100644 index 0000000000..f0077c3334 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_locationBarCommand.js @@ -0,0 +1,291 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test is designed to ensure that the correct command/operation happens + * when pressing enter with various key combinations in the urlbar. + */ + +const TEST_VALUE = "example.com"; +const START_VALUE = "example.org"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.altClickSave", true], + ["browser.urlbar.autoFill", false], + ], + }); +}); + +add_task(async function alt_left_click_test() { + info("Running test: Alt left click"); + + // Monkey patch saveURL() to avoid dealing with file save code paths. + let oldSaveURL = saveURL; + let saveURLPromise = new Promise(resolve => { + saveURL = () => { + // Restore old saveURL() value. + saveURL = oldSaveURL; + resolve(); + }; + }); + + await triggerCommand("click", { altKey: true }); + + await saveURLPromise; + ok(true, "SaveURL was called"); + is(gURLBar.value, "", "Urlbar reverted to original value"); +}); + +add_task(async function shift_left_click_test() { + info("Running test: Shift left click"); + + let destinationURL = "http://" + TEST_VALUE + "/"; + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: destinationURL, + }); + await triggerCommand("click", { shiftKey: true }); + let win = await newWindowPromise; + + info("URL should be loaded in a new window"); + is(gURLBar.value, "", "Urlbar reverted to original value"); + await promiseCheckChildNoFocusedElement(gBrowser.selectedBrowser); + is( + document.activeElement, + gBrowser.selectedBrowser, + "Content window should be focused" + ); + is(win.gURLBar.value, TEST_VALUE, "New URL is loaded in new window"); + + // Cleanup. + let ourWindowRefocusedPromise = Promise.all([ + BrowserTestUtils.waitForEvent(window, "activate"), + BrowserTestUtils.waitForEvent(window, "focus", true), + ]); + await BrowserTestUtils.closeWindow(win); + await ourWindowRefocusedPromise; +}); + +add_task(async function right_click_test() { + info("Running test: Right click on go button"); + + // Add a new tab. + await promiseOpenNewTab(); + + await triggerCommand("click", { button: 2 }); + + // Right click should do nothing (context menu will be shown). + is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered"); + + // Cleanup. + gBrowser.removeCurrentTab(); +}); + +add_task(async function shift_accel_left_click_test() { + info("Running test: Shift+Ctrl/Cmd left click on go button"); + + // Add a new tab. + let tab = await promiseOpenNewTab(); + + let loadStartedPromise = promiseLoadStarted(); + await triggerCommand("click", { accelKey: true, shiftKey: true }); + await loadStartedPromise; + + // Check the load occurred in a new background tab. + info("URL should be loaded in a new background tab"); + is(gURLBar.value, "", "Urlbar reverted to original value"); + ok(!gURLBar.focused, "Urlbar is no longer focused after urlbar command"); + is(gBrowser.selectedTab, tab, "Focus did not change to the new tab"); + + // Select the new background tab + gBrowser.selectedTab = gBrowser.selectedTab.nextElementSibling; + is(gURLBar.value, TEST_VALUE, "New URL is loaded in new tab"); + + // Cleanup. + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); +}); + +add_task(async function load_in_current_tab_test() { + let tests = [ + { + desc: "Simple return keypress", + type: "keypress", + }, + { + desc: "Left click on go button", + type: "click", + }, + { + desc: "Ctrl/Cmd+Return keypress", + type: "keypress", + details: { accelKey: true }, + }, + { + desc: "Alt+Return keypress in a blank tab", + type: "keypress", + details: { altKey: true }, + }, + { + desc: "AltGr+Return keypress in a blank tab", + type: "keypress", + details: { altGraphKey: true }, + }, + ]; + + for (let { desc, type, details } of tests) { + info(`Running test: ${desc}`); + + // Add a new tab. + let tab = await promiseOpenNewTab(); + + // Trigger a load and check it occurs in the current tab. + let loadStartedPromise = promiseLoadStarted(); + await triggerCommand(type, details); + await loadStartedPromise; + + info("URL should be loaded in the current tab"); + is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered"); + await promiseCheckChildNoFocusedElement(gBrowser.selectedBrowser); + is( + document.activeElement, + gBrowser.selectedBrowser, + "Content window should be focused" + ); + is(gBrowser.selectedTab, tab, "New URL was loaded in the current tab"); + + // Cleanup. + gBrowser.removeCurrentTab(); + } +}); + +add_task(async function load_in_new_tab_test() { + let tests = [ + { + desc: "Ctrl/Cmd left click on go button", + type: "click", + details: { accelKey: true }, + url: "about:blank", + }, + { + desc: "Alt+Return keypress in a dirty tab", + type: "keypress", + details: { altKey: true }, + url: START_VALUE, + }, + { + desc: "AltGr+Return keypress in a dirty tab", + type: "keypress", + details: { altGraphKey: true }, + url: START_VALUE, + }, + ]; + + for (let { desc, type, details, url } of tests) { + info(`Running test: ${desc}`); + + // Add a new tab. + let tab = await promiseOpenNewTab(url); + + // Trigger a load and check it occurs in a new tab. + let tabSwitchedPromise = promiseNewTabSwitched(); + await triggerCommand(type, details); + await tabSwitchedPromise; + + // Check the load occurred in a new tab. + info("URL should be loaded in a new focused tab"); + is(gURLBar.value, TEST_VALUE, "Urlbar still has the value we entered"); + await promiseCheckChildNoFocusedElement(gBrowser.selectedBrowser); + is( + document.activeElement, + gBrowser.selectedBrowser, + "Content window should be focused" + ); + isnot(gBrowser.selectedTab, tab, "New URL was loaded in a new tab"); + + // Cleanup. + gBrowser.removeCurrentTab(); + gBrowser.removeCurrentTab(); + } +}); + +async function triggerCommand(type, details = {}) { + gURLBar.focus(); + gURLBar.value = ""; + EventUtils.sendString(TEST_VALUE); + + Assert.equal( + await UrlbarTestUtils.promiseUserContextId(window), + gBrowser.selectedTab.getAttribute("usercontextid"), + "userContextId must be the same as the originating tab" + ); + + if (type == "click") { + ok( + gURLBar.hasAttribute("usertyping"), + "usertyping attribute must be set for the go button to be visible" + ); + EventUtils.synthesizeMouseAtCenter(gURLBar.goButton, details); + } else if (type == "keypress") { + EventUtils.synthesizeKey("KEY_Enter", details); + } else { + throw new Error("Unsupported event type"); + } +} + +function promiseLoadStarted() { + return new Promise(resolve => { + gBrowser.addTabsProgressListener({ + onStateChange(browser, webProgress, req, flags, status) { + if (flags & Ci.nsIWebProgressListener.STATE_START) { + gBrowser.removeTabsProgressListener(this); + resolve(); + } + }, + }); + }); +} + +let gUserContextIdSerial = 1; +async function promiseOpenNewTab(url = "about:blank") { + let tab = BrowserTestUtils.addTab(gBrowser, url, { + userContextId: gUserContextIdSerial++, + }); + let tabSwitchPromise = promiseNewTabSwitched(tab); + gBrowser.selectedTab = tab; + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await tabSwitchPromise; + return tab; +} + +function promiseNewTabSwitched() { + return new Promise(resolve => { + gBrowser.addEventListener( + "TabSwitchDone", + function () { + executeSoon(resolve); + }, + { once: true } + ); + }); +} + +function promiseCheckChildNoFocusedElement(browser) { + if (!gMultiProcessBrowser) { + Assert.equal( + Services.focus.focusedElement, + null, + "There should be no focused element" + ); + return null; + } + + return ContentTask.spawn(browser, null, async function () { + Assert.equal( + Services.focus.focusedElement, + null, + "There should be no focused element" + ); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_locationBarExternalLoad.js b/browser/components/urlbar/tests/browser/browser_locationBarExternalLoad.js new file mode 100644 index 0000000000..5a44db54ce --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_locationBarExternalLoad.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + const url = "data:text/html,<body>hi"; + await testURL(url, urlEnter); + await testURL(url, urlClick); +}); + +function urlEnter(url) { + gURLBar.value = url; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); +} + +function urlClick(url) { + gURLBar.focus(); + gURLBar.value = ""; + EventUtils.sendString(url); + EventUtils.synthesizeMouseAtCenter(gURLBar.goButton, {}); +} + +function promiseNewTabSwitched() { + return new Promise(resolve => { + gBrowser.addEventListener( + "TabSwitchDone", + function () { + executeSoon(resolve); + }, + { once: true } + ); + }); +} + +function promiseLoaded(browser) { + return SpecialPowers.spawn(browser, [undefined], async () => { + if (!["interactive", "complete"].includes(content.document.readyState)) { + await new Promise(resolve => + docShell.chromeEventHandler.addEventListener( + "DOMContentLoaded", + resolve, + { + once: true, + capture: true, + } + ) + ); + } + }); +} + +async function testURL(url, loadFunc, endFunc) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let browser = tab.linkedBrowser; + + let pagePrincipal = gBrowser.contentPrincipal; + // We need to ensure that we set the pageshow event listener before running + // loadFunc, otherwise there's a chance that the content process will finish + // loading the page and fire pageshow before the event listener gets set. + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + loadFunc(url); + await pageShowPromise; + + await SpecialPowers.spawn( + browser, + [{ isRemote: gMultiProcessBrowser }], + async function (arg) { + Assert.equal( + Services.focus.focusedElement, + null, + "focusedElement not null" + ); + } + ); + + is(document.activeElement, browser, "content window should be focused"); + + ok( + !gBrowser.contentPrincipal.equals(pagePrincipal), + "load of " + + url + + " by " + + loadFunc.name + + " should produce a page with a different principal" + ); + + await BrowserTestUtils.removeTab(tab); +} diff --git a/browser/components/urlbar/tests/browser/browser_locationchange_urlbar_edit_dos.js b/browser/components/urlbar/tests/browser/browser_locationchange_urlbar_edit_dos.js new file mode 100644 index 0000000000..b50446a4c9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_locationchange_urlbar_edit_dos.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}file_urlbar_edit_dos.html`; + +async function checkURLBarValueStays(browser) { + gURLBar.select(); + EventUtils.sendString("a"); + is(gURLBar.value, "a", "URL bar value should match after sending a key"); + await new Promise(resolve => { + let listener = { + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + ok( + aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT, + "Should only get a same document location change" + ); + gBrowser.selectedBrowser.removeProgressListener(filter); + filter = null; + // Wait an extra tick before resolving. We want to make sure that other + // web progress listeners queued after this one are called before we + // continue the test, in case the remainder of the test depends on those + // listeners. That should happen anyway since promises are resolved on + // the next tick, but do this to be a little safer. In particular we + // want to avoid having the test pass when it should fail. + executeSoon(resolve); + }, + }; + let filter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + filter.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL); + gBrowser.selectedBrowser.addProgressListener(filter); + }); + is( + gURLBar.value, + "a", + "URL bar should not have been changed by location changes." + ); +} + +add_task(async function () { + // Disable autofill so that when checkURLBarValueStays types "a", it's not + // autofilled to addons.mozilla.org (or anything else). + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: TEST_URL, + }, + async function (browser) { + let promise1 = checkURLBarValueStays(browser); + SpecialPowers.spawn(browser, [""], function () { + content.wrappedJSObject.dos_hash(); + }); + await promise1; + let promise2 = checkURLBarValueStays(browser); + SpecialPowers.spawn(browser, [""], function () { + content.wrappedJSObject.dos_pushState(); + }); + await promise2; + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_middleClick.js b/browser/components/urlbar/tests/browser/browser_middleClick.js new file mode 100644 index 0000000000..1a5088f827 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_middleClick.js @@ -0,0 +1,255 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test for middle click behavior. + */ + +add_task(async function test_setup() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.searchclipboardfor.middleclick", false]], + }); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("middlemouse.paste"); + Services.prefs.clearUserPref("middlemouse.openNewWindow"); + Services.prefs.clearUserPref("browser.tabs.opentabfor.middleclick"); + Services.prefs.clearUserPref("browser.startup.homepage"); + Services.prefs.clearUserPref("browser.tabs.loadBookmarksInBackground"); + SpecialPowers.clipboardCopyString(""); + + CustomizableUI.removeWidgetFromArea("home-button"); + }); +}); + +add_task(async function test_middleClickOnTab() { + await testMiddleClickOnTab(false); + await testMiddleClickOnTab(true); +}); + +add_task(async function test_middleClickToOpenNewTab() { + await testMiddleClickToOpenNewTab(false, "#tabs-newtab-button"); + await testMiddleClickToOpenNewTab(true, "#tabs-newtab-button"); + await testMiddleClickToOpenNewTab(false, "#TabsToolbar"); + await testMiddleClickToOpenNewTab(true, "#TabsToolbar"); +}); + +add_task(async function test_middleClickOnURLBar() { + await testMiddleClickOnURLBar(false); + await testMiddleClickOnURLBar(true); +}); + +add_task(async function test_middleClickOnHomeButton() { + const TEST_DATA = [ + { + isMiddleMousePastePrefOn: false, + isLoadInBackground: false, + startPagePref: "about:home", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: false, + isLoadInBackground: false, + startPagePref: "about:blank", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: false, + isLoadInBackground: false, + startPagePref: "https://example.com", + expectedURLBarFocus: false, + expectedURLBarValue: "https://example.com", + }, + { + isMiddleMousePastePrefOn: true, + isLoadInBackground: false, + startPagePref: "about:home", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: true, + isLoadInBackground: false, + startPagePref: "https://example.com", + expectedURLBarFocus: false, + expectedURLBarValue: "https://example.com", + }, + { + isMiddleMousePastePrefOn: false, + isLoadInBackground: true, + startPagePref: "about:home", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: false, + isLoadInBackground: true, + startPagePref: "https://example.com", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: true, + isLoadInBackground: true, + startPagePref: "about:home", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + { + isMiddleMousePastePrefOn: true, + isLoadInBackground: true, + startPagePref: "https://example.com", + expectedURLBarFocus: true, + expectedURLBarValue: "", + }, + ]; + + for (const testData of TEST_DATA) { + await testMiddleClickOnHomeButton(testData); + } +}); + +add_task(async function test_middleClickOnHomeButtonWithNewWindow() { + await testMiddleClickOnHomeButtonWithNewWindow(false); + await testMiddleClickOnHomeButtonWithNewWindow(true); +}); + +async function testMiddleClickOnTab(isMiddleMousePastePrefOn) { + info(`Set middlemouse.paste [${isMiddleMousePastePrefOn}]`); + Services.prefs.setBoolPref("middlemouse.paste", isMiddleMousePastePrefOn); + + info("Set initial value"); + SpecialPowers.clipboardCopyString("test\nsample"); + gURLBar.value = ""; + gURLBar.focus(); + + info("Open two tabs"); + const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Middle click on tab2 to remove it"); + EventUtils.synthesizeMouseAtCenter(tab2, { button: 1 }); + + info("Wait until the tab1 is selected"); + await TestUtils.waitForCondition(() => gBrowser.selectedTab === tab1); + + Assert.equal(gURLBar.value, "", "URLBar has no pasted value"); + + BrowserTestUtils.removeTab(tab1); +} + +async function testMiddleClickToOpenNewTab(isMiddleMousePastePrefOn, selector) { + info(`Set middlemouse.paste [${isMiddleMousePastePrefOn}]`); + Services.prefs.setBoolPref("middlemouse.paste", isMiddleMousePastePrefOn); + + info("Set initial value"); + SpecialPowers.clipboardCopyString("test\nsample"); + gURLBar.value = ""; + gURLBar.focus(); + + info(`Click on ${selector}`); + const originalTab = gBrowser.selectedTab; + const element = document.querySelector(selector); + EventUtils.synthesizeMouseAtCenter(element, { button: 1 }); + + info("Wait until the new tab is opened"); + await TestUtils.waitForCondition(() => gBrowser.selectedTab !== originalTab); + + Assert.equal(gURLBar.value, "", "URLBar has no pasted value"); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +} + +async function testMiddleClickOnURLBar(isMiddleMousePastePrefOn) { + info(`Set middlemouse.paste [${isMiddleMousePastePrefOn}]`); + Services.prefs.setBoolPref("middlemouse.paste", isMiddleMousePastePrefOn); + + info("Set initial value"); + SpecialPowers.clipboardCopyString("test\nsample"); + gURLBar.value = ""; + gURLBar.focus(); + + info("Middle click on the urlbar"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { button: 1 }); + + if (isMiddleMousePastePrefOn) { + Assert.equal(gURLBar.value, "test sample", "URLBar has pasted value"); + } else { + Assert.equal(gURLBar.value, "", "URLBar has no pasted value"); + } +} + +async function testMiddleClickOnHomeButton({ + isMiddleMousePastePrefOn, + isLoadInBackground, + startPagePref, + expectedURLBarFocus, + expectedURLBarValue, +}) { + info(`middlemouse.paste [${isMiddleMousePastePrefOn}]`); + info(`browser.startup.homepage [${startPagePref}]`); + info(`browser.tabs.loadBookmarksInBackground [${isLoadInBackground}]`); + + info("Set initial value"); + Services.prefs.setCharPref("browser.startup.homepage", startPagePref); + Services.prefs.setBoolPref( + "browser.tabs.loadBookmarksInBackground", + isLoadInBackground + ); + Services.prefs.setBoolPref("middlemouse.paste", isMiddleMousePastePrefOn); + SpecialPowers.clipboardCopyString("test\nsample"); + gURLBar.value = ""; + gURLBar.focus(); + + info("Middle click on the home button"); + const currentTab = gBrowser.selectedTab; + const homeButton = document.getElementById("home-button"); + EventUtils.synthesizeMouseAtCenter(homeButton, { button: 1 }); + + if (!isLoadInBackground) { + info("Wait until the a new tab is selected"); + await TestUtils.waitForCondition(() => gBrowser.selectedTab !== currentTab); + } + + info("Wait until the focus moves"); + await TestUtils.waitForCondition( + () => + (document.activeElement === gURLBar.inputField) === expectedURLBarFocus + ); + + Assert.ok(true, "The focus is correct"); + Assert.equal(gURLBar.value, expectedURLBarValue, "URLBar value is correct"); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +} + +async function testMiddleClickOnHomeButtonWithNewWindow( + isMiddleMousePastePrefOn +) { + info(`Set middlemouse.paste [${isMiddleMousePastePrefOn}]`); + Services.prefs.setBoolPref("middlemouse.paste", isMiddleMousePastePrefOn); + + info("Set prefs to open in a new window"); + Services.prefs.setBoolPref("middlemouse.openNewWindow", true); + Services.prefs.setBoolPref("browser.tabs.opentabfor.middleclick", false); + + info("Set initial value"); + SpecialPowers.clipboardCopyString("test\nsample"); + gURLBar.value = ""; + gURLBar.focus(); + + info("Middle click on the home button"); + const homeButton = document.getElementById("home-button"); + const onNewWindowOpened = BrowserTestUtils.waitForNewWindow(); + EventUtils.synthesizeMouseAtCenter(homeButton, { button: 1 }); + + const newWindow = await onNewWindowOpened; + Assert.equal(newWindow.gURLBar.value, "", "URLBar value is correct"); + + await BrowserTestUtils.closeWindow(newWindow); +} diff --git a/browser/components/urlbar/tests/browser/browser_new_tab_urlbar_reset.js b/browser/components/urlbar/tests/browser/browser_new_tab_urlbar_reset.js new file mode 100644 index 0000000000..b2bce4b22e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_new_tab_urlbar_reset.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Verify that urlbar state is reset when opening a new tab, so searching for the + * same text will reopen the results popup. + */ +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank", + false + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "m", + }); + assertOpen(); + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank", + false + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "m", + }); + assertOpen(); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +function assertOpen() { + Assert.equal(gURLBar.view.isOpen, true, "Should be showing the popup"); +} diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs.js b/browser/components/urlbar/tests/browser/browser_oneOffs.js new file mode 100644 index 0000000000..d9a6a8d416 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs.js @@ -0,0 +1,980 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the one-off search buttons in the urlbar. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +let gMaxResults; +let engine; + +XPCOMUtils.defineLazyGetter(this, "oneOffSearchButtons", () => { + return UrlbarTestUtils.getOneOffSearchButtons(window); +}); + +add_setup(async function () { + gMaxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + await Services.search.moveEngine(engine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + // Initialize history with enough visits to fill up the view. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + for (let i = 0; i < gMaxResults; i++) { + await PlacesTestUtils.addVisits( + "http://example.com/browser_urlbarOneOffs.js/?" + i + ); + } + + // Add some more visits to the last URL added above so that the top-sites view + // will be non-empty. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits( + "http://example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - 1) + ); + } + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url.startsWith("http://example.com/"); + }); + + // Move the mouse away from the view so that a result or one-off isn't + // inadvertently highlighted. See bug 1659011. + EventUtils.synthesizeMouse( + gURLBar.inputField, + 0, + 0, + { type: "mousemove" }, + window + ); +}); + +// Opens the view without showing the one-offs. They should be hidden and arrow +// key selection should work properly. +add_task(async function noOneOffs() { + // Do a search for "@" since we hide the one-offs in that case. + let value = "@"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + fireInputEvent: true, + }); + await TestUtils.waitForCondition( + () => !oneOffSearchButtons._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + false, + "One-offs should be hidden" + ); + assertState(-1, -1, value); + + // Get the result count. We don't particularly care what the results are, + // just what the count is so that we can key through them all. + let resultCount = UrlbarTestUtils.getResultCount(window); + + // Key down through all results. + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(i, -1); + } + + // Key down again. Nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(-1, -1, value); + + // Key down again. The first result should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(0, -1); + + // Key up. Nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, -1, value); + + // Key up through all the results. + for (let i = resultCount - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(i, -1); + } + + // Key up again. Nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, -1, value); + + await hidePopup(); +}); + +// Opens the top-sites view. The one-offs should be shown. +add_task(async function topSites() { + // Do a search that shows top sites. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await TestUtils.waitForCondition( + () => !oneOffSearchButtons._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + // There's one top sites result, the page with a lot of visits from init. + let resultURL = "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - 1); + Assert.equal(UrlbarTestUtils.getResultCount(window), 1, "Result count"); + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + assertState(-1, -1, ""); + + // Key down into the result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(0, -1, resultURL); + + // Key down through each one-off. + let numButtons = oneOffSearchButtons.getSelectableButtons(true).length; + for (let i = 0; i < numButtons; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(-1, i, ""); + } + + // Key down again. The selection should go away. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(-1, -1, ""); + + // Key down again. The result should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(0, -1, resultURL); + + // Key back up. The selection should go away. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, -1, ""); + + // Key up again. The selection should wrap back around to the one-offs. Key + // up through all the one-offs. + for (let i = numButtons - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, i, ""); + } + + // Key up. The result should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(0, -1, resultURL); + + // Key up again. The selection should go away. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, -1, ""); + + await hidePopup(); +}); + +// Keys up and down through the non-top-sites view, i.e., the view that's shown +// when the input has been edited. +add_task(async function editedView() { + // Use a typed value that returns the visits added above but that doesn't + // trigger autofill since that would complicate the test. + let typedValue = "browser_urlbarOneOffs"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, gMaxResults - 1); + let heuristicResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + assertState(0, -1, typedValue); + + // Key down through each result. The first result is already selected, which + // is why gMaxResults - 1 is the correct number of times to do this. + for (let i = 0; i < gMaxResults - 1; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + // i starts at zero so that the textValue passed to assertState is correct. + // But that means that i + 1 is the expected selected index, since initially + // (when this loop starts) the first result is selected. + assertState( + i + 1, + -1, + "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1) + ); + Assert.ok( + !BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should not be visible" + ); + } + + // Key down through each one-off. + let numButtons = oneOffSearchButtons.getSelectableButtons(true).length; + for (let i = 0; i < numButtons; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(-1, i, typedValue); + Assert.equal( + BrowserTestUtils.is_visible(heuristicResult.element.action), + !oneOffSearchButtons.selectedButton.classList.contains( + "search-setting-button" + ), + "The heuristic action should be visible when a one-off button is selected" + ); + } + + // Key down once more. The selection should wrap around to the first result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + assertState(0, -1, typedValue); + Assert.ok( + BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should be visible" + ); + + // Now key up. The selection should wrap back around to the one-offs. Key + // up through all the one-offs. + for (let i = numButtons - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(-1, i, typedValue); + Assert.equal( + BrowserTestUtils.is_visible(heuristicResult.element.action), + !oneOffSearchButtons.selectedButton.classList.contains( + "search-setting-button" + ), + "The heuristic action should be visible when a one-off button is selected" + ); + } + + // Key up through each non-heuristic result. + for (let i = gMaxResults - 2; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState( + i + 1, + -1, + "example.com/browser_urlbarOneOffs.js/?" + (gMaxResults - i - 1) + ); + Assert.ok( + !BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should not be visible" + ); + } + + // Key up once more. The heuristic result should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(0, -1, typedValue); + Assert.ok( + BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should be visible" + ); + + await hidePopup(); +}); + +// Checks that "Search with Current Search Engine" items are updated to "Search +// with One-Off Engine" when a one-off is selected. +add_task(async function searchWith() { + // Enable suggestions for this subtest so we can check non-heuristic results. + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + + let typedValue = "foo"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + assertState(0, -1, typedValue); + + Assert.equal( + result.displayed.action, + "Search with " + (await Services.search.getDefault()).name, + "Sanity check: first result's action text" + ); + + // Alt+Down to the second one-off. Now the first result and the second + // one-off should both be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: 2 }); + assertState(0, 1, typedValue); + + let engineName = oneOffSearchButtons.selectedButton.engine.name; + Assert.notEqual( + engineName, + (await Services.search.getDefault()).name, + "Sanity check: Second one-off engine should not be the current engine" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.displayed.action, + "Search with " + engineName, + "First result's action text should be updated" + ); + + // Check non-heuristic results. + await hidePopup(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + }); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + assertState(1, -1, typedValue + "foo"); + Assert.equal( + result.displayed.action, + "Search with " + engine.name, + "Sanity check: second result's action text" + ); + Assert.ok(!result.heuristic, "The second result is not heuristic."); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: 2 }); + assertState(1, 1, typedValue + "foo"); + + engineName = oneOffSearchButtons.selectedButton.engine.name; + Assert.notEqual( + engineName, + (await Services.search.getDefault()).name, + "Sanity check: Second one-off engine should not be the current engine" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + Assert.equal( + result.displayed.action, + "Search with " + engineName, + "Second result's action text should be updated" + ); + + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await hidePopup(); +}); + +// Clicks a one-off with an engine. +add_task(async function oneOffClick() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + + // We are explicitly using something that looks like a url, to make the test + // stricter. Even if it looks like a url, we should search. + let typedValue = "foo.bar"; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + }); + await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + assertState(0, -1, typedValue); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(oneOffs[0], {}); + await searchPromise; + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is still open."); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + + gBrowser.removeTab(gBrowser.selectedTab); + await UrlbarTestUtils.formHistory.clear(); +}); + +// Presses the Return key when a one-off with an engine is selected. +add_task(async function oneOffReturn() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + + // We are explicitly using something that looks like a url, to make the test + // stricter. Even if it looks like a url, we should search. + let typedValue = "foo.bar"; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + assertState(0, -1, typedValue); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + + // Alt+Down to select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + assertState(0, 0, typedValue); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is still open."); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + + gBrowser.removeTab(gBrowser.selectedTab); + await UrlbarTestUtils.formHistory.clear(); + await hidePopup(); +}); + +// When all engines and local shortcuts are hidden except for the current +// engine, the one-offs container should be hidden. +add_task(async function allOneOffsHiddenExceptCurrentEngine() { + // Disable all the engines but the current one, check the oneoffs are + // hidden and that moving up selects the last match. + let defaultEngine = await Services.search.getDefault(); + let engines = (await Services.search.getVisibleEngines()).filter( + e => e.name != defaultEngine.name + ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.hiddenOneOffs", engines.map(e => e.name).join(",")], + ...UrlbarUtils.LOCAL_SEARCH_MODES.map(m => [ + `browser.urlbar.${m.pref}`, + false, + ]), + ], + }); + + let typedValue = "foo"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + assertState(0, -1); + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + false, + "The one-off buttons should be hidden" + ); + EventUtils.synthesizeKey("KEY_ArrowUp"); + assertState(0, -1); + await hidePopup(); + await SpecialPowers.popPrefEnv(); +}); + +// The one-offs should be hidden when searching with an "@engine" search engine +// alias. +add_task(async function hiddenWhenUsingSearchAlias() { + let typedValue = "@example"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + false, + "Should not be showing the one-off buttons" + ); + await hidePopup(); + + typedValue = "not an engine alias"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "Should be showing the one-off buttons" + ); + await hidePopup(); +}); + +// Makes sure the local shortcuts exist. +add_task(async function localShortcuts() { + oneOffSearchButtons.invalidateCache(); + await doLocalShortcutsShownTest(); +}); + +// Clicks a local shortcut button. +add_task(async function localShortcutClick() { + // We are explicitly using something that looks like a url, to make the test + // stricter. Even if it looks like a url, we should search. + let typedValue = "foo.bar"; + + oneOffSearchButtons.invalidateCache(); + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + }); + await rebuildPromise; + + let buttons = oneOffSearchButtons.localButtons; + Assert.ok(buttons.length, "Sanity check: Local shortcuts exist"); + + for (let button of buttons) { + Assert.ok(button.source, "Sanity check: Button has a source"); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(button, {}); + await searchPromise; + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "Urlbar view is still open." + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: button.source, + entry: "oneoff", + }); + } + + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await hidePopup(); +}); + +// Presses the Return key when a local shortcut is selected. +add_task(async function localShortcutReturn() { + // We are explicitly using something that looks like a url, to make the test + // stricter. Even if it looks like a url, we should search. + let typedValue = "foo.bar"; + + oneOffSearchButtons.invalidateCache(); + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + }); + await rebuildPromise; + + let buttons = oneOffSearchButtons.localButtons; + Assert.ok(buttons.length, "Sanity check: Local shortcuts exist"); + + let allButtons = oneOffSearchButtons.getSelectableButtons(false); + let firstLocalIndex = allButtons.length - buttons.length; + + for (let i = 0; i < buttons.length; i++) { + let button = buttons[i]; + + // Alt+Down enough times to select the button. + let index = firstLocalIndex + i; + EventUtils.synthesizeKey("KEY_ArrowDown", { + altKey: true, + repeat: index + 1, + }); + await TestUtils.waitForCondition( + () => oneOffSearchButtons.selectedButtonIndex == index, + "Waiting for local shortcut to become selected" + ); + + let expectedSelectedResultIndex = -1; + let count = UrlbarTestUtils.getResultCount(window); + if (count > 0) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + if (result.heuristic) { + expectedSelectedResultIndex = 0; + } + } + assertState(expectedSelectedResultIndex, index, typedValue); + + Assert.ok(button.source, "Sanity check: Button has a source"); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "Urlbar view is still open." + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: button.source, + entry: "oneoff", + }); + } + + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await hidePopup(); +}); + +// With an empty search string, clicking a local shortcut should result in no +// heuristic result. +add_task(async function localShortcutEmptySearchString() { + oneOffSearchButtons.invalidateCache(); + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await rebuildPromise; + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + let buttons = oneOffSearchButtons.localButtons; + Assert.ok(buttons.length, "Sanity check: Local shortcuts exist"); + + for (let button of buttons) { + Assert.ok(button.source, "Sanity check: Button has a source"); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(button, {}); + await searchPromise; + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "Urlbar view is still open." + ); + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: button.source, + entry: "oneoff", + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + if (!resultCount) { + Assert.equal( + gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + continue; + } + Assert.ok( + !gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!result.heuristic, "The first result should not be heuristic"); + } + + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + + await hidePopup(); +}); + +// Trigger SearchOneOffs.willHide() outside of SearchOneOffs.__rebuild(). Ensure +// that we always show the correct engines in the one-offs. This effectively +// tests SearchOneOffs._engineInfo.domWasUpdated. +add_task(async function avoidWillHideRace() { + // We set maxHistoricalSearchSuggestions to 0 since this test depends on + // UrlbarView calling SearchOneOffs.willHide(). That only happens when the + // Urlbar is in search mode after a query that returned no results. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + oneOffSearchButtons.invalidateCache(); + + // Accel+K triggers SearchOneOffs.willHide() from UrlbarView instead of from + // SearchOneOffs.__rebuild. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("k", { accelKey: true }); + await searchPromise; + Assert.ok( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + "One-offs should be visible" + ); + await UrlbarTestUtils.promisePopupClose(window); + + info("Hide all engines but the test engine."); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let engines = (await Services.search.getVisibleEngines()).filter( + e => e.name != engine.name + ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.hiddenOneOffs", engines.map(e => e.name).join(",")], + ...UrlbarUtils.LOCAL_SEARCH_MODES.map(m => [ + `browser.urlbar.${m.pref}`, + false, + ]), + ], + }); + Assert.ok( + !oneOffSearchButtons._engineInfo, + "_engineInfo should be nulled out." + ); + + // This call to SearchOneOffs.willHide() should repopulate _engineInfo but not + // rebuild the one-offs. _engineInfo.willHide will be true and thus UrlbarView + // will not open. + EventUtils.synthesizeKey("k", { accelKey: true }); + // We can't wait for UrlbarTestUtils.promiseSearchComplete here since we + // expect the popup will not open. We wait for _engineInfo to be populated + // instead. + await BrowserTestUtils.waitForCondition( + () => !!oneOffSearchButtons._engineInfo, + "_engineInfo is set." + ); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "The UrlbarView is closed."); + Assert.equal( + oneOffSearchButtons._engineInfo.willHide, + true, + "_engineInfo should be repopulated and willHide should be true." + ); + Assert.equal( + oneOffSearchButtons._engineInfo.domWasUpdated, + undefined, + "domWasUpdated should not be populated since we haven't yet tried to rebuild the one-offs." + ); + + // Now search. The view will open and the one-offs will rebuild, although + // the one-offs will not be shown since there is only one engine. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + Assert.equal( + oneOffSearchButtons._engineInfo.domWasUpdated, + true, + "domWasUpdated should be true" + ); + Assert.ok( + !UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + "One-offs should be hidden since there is only one engine." + ); + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await SpecialPowers.popPrefEnv(); +}); + +// Hides each of the local shortcuts one at a time. The search buttons should +// automatically rebuild themselves. +add_task(async function individualLocalShortcutsHidden() { + for (let { pref, source } of UrlbarUtils.LOCAL_SEARCH_MODES) { + await SpecialPowers.pushPrefEnv({ + set: [[`browser.urlbar.${pref}`, false]], + }); + + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await rebuildPromise; + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + let buttons = oneOffSearchButtons.localButtons; + Assert.ok(buttons.length, "Sanity check: Local shortcuts exist"); + + let otherModes = UrlbarUtils.LOCAL_SEARCH_MODES.filter( + m => m.source != source + ); + Assert.equal( + buttons.length, + otherModes.length, + "Expected number of enabled local shortcut buttons" + ); + + for (let i = 0; i < buttons.length; i++) { + Assert.equal( + buttons[i].source, + otherModes[i].source, + "Button has the expected source" + ); + } + + await hidePopup(); + await SpecialPowers.popPrefEnv(); + } +}); + +// Hides all the local shortcuts at once. +add_task(async function allLocalShortcutsHidden() { + await SpecialPowers.pushPrefEnv({ + set: UrlbarUtils.LOCAL_SEARCH_MODES.map(m => [ + `browser.urlbar.${m.pref}`, + false, + ]), + }); + + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await rebuildPromise; + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + Assert.equal( + oneOffSearchButtons.localButtons.length, + 0, + "All local shortcuts should be hidden" + ); + + Assert.greater( + oneOffSearchButtons.getSelectableButtons(false).filter(b => b.engine) + .length, + 0, + "Engine one-offs should not be hidden" + ); + + await hidePopup(); + await SpecialPowers.popPrefEnv(); +}); + +// Hides all the engines but none of the local shortcuts. +add_task(async function localShortcutsShownWhenEnginesHidden() { + let engines = await Services.search.getVisibleEngines(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.hiddenOneOffs", engines.map(e => e.name).join(",")]], + }); + + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await rebuildPromise; + + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + Assert.equal( + oneOffSearchButtons.localButtons.length, + UrlbarUtils.LOCAL_SEARCH_MODES.length, + "All local shortcuts are visible" + ); + + Assert.equal( + oneOffSearchButtons.getSelectableButtons(false).filter(b => b.engine) + .length, + 0, + "All engine one-offs are hidden" + ); + + await hidePopup(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Checks that the local shortcuts are shown correctly. + */ +async function doLocalShortcutsShownTest() { + let rebuildPromise = BrowserTestUtils.waitForEvent( + oneOffSearchButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "doLocalShortcutsShownTest", + }); + await rebuildPromise; + + let buttons = oneOffSearchButtons.localButtons; + Assert.equal(buttons.length, 4, "Expected number of local shortcuts"); + + let expectedSource; + let seenIDs = new Set(); + for (let button of buttons) { + Assert.ok( + !seenIDs.has(button.id), + "Should not have already seen button.id" + ); + seenIDs.add(button.id); + switch (button.id) { + case "urlbar-engine-one-off-item-bookmarks": + expectedSource = UrlbarUtils.RESULT_SOURCE.BOOKMARKS; + break; + case "urlbar-engine-one-off-item-tabs": + expectedSource = UrlbarUtils.RESULT_SOURCE.TABS; + break; + case "urlbar-engine-one-off-item-history": + expectedSource = UrlbarUtils.RESULT_SOURCE.HISTORY; + break; + case "urlbar-engine-one-off-item-actions": + expectedSource = UrlbarUtils.RESULT_SOURCE.ACTIONS; + break; + default: + Assert.ok(false, `Unexpected local shortcut ID: ${button.id}`); + break; + } + Assert.equal(button.source, expectedSource, "Expected button.source"); + } + + await hidePopup(); +} + +function assertState(result, oneOff, textValue = undefined) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + result, + "Expected result should be selected" + ); + Assert.equal( + oneOffSearchButtons.selectedButtonIndex, + oneOff, + "Expected one-off should be selected" + ); + if (textValue !== undefined) { + Assert.equal(gURLBar.value, textValue, "Expected value"); + } +} + +function hidePopup() { + return UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs_contextMenu.js b/browser/components/urlbar/tests/browser/browser_oneOffs_contextMenu.js new file mode 100644 index 0000000000..60d46608cd --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_contextMenu.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that the right-click menu works correctly for the one-off buttons. + */ + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +let gMaxResults; + +XPCOMUtils.defineLazyGetter(this, "oneOffSearchButtons", () => { + return UrlbarTestUtils.getOneOffSearchButtons(window); +}); + +let originalEngine; +let newEngine; + +// The one-off context menu should not be shown. +add_task(async function contextMenu_not_shown() { + // Add a popupshown listener on the context menu that sets this + // popupshownFired boolean. + let popupshownFired = false; + let onPopupshown = () => { + popupshownFired = true; + }; + let contextMenu = oneOffSearchButtons.querySelector( + ".search-one-offs-context-menu" + ); + contextMenu.addEventListener("popupshown", onPopupshown); + + // Do a search to open the view. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + // First, try to open the context menu on a remote engine. + let allOneOffs = oneOffSearchButtons.getSelectableButtons(true); + Assert.greater(allOneOffs.length, 0, "There should be at least one one-off"); + Assert.ok( + allOneOffs[0].engine, + "The first one-off should be a remote one-off" + ); + EventUtils.synthesizeMouseAtCenter(allOneOffs[0], { + type: "contextmenu", + button: 2, + }); + let timeout = 500; + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, timeout)); + Assert.ok( + !popupshownFired, + "popupshown should not be fired on a remote one-off" + ); + + // Now try to open the context menu on a local one-off. + let localOneOffs = oneOffSearchButtons.localButtons; + Assert.greater( + localOneOffs.length, + 0, + "There should be at least one local one-off" + ); + EventUtils.synthesizeMouseAtCenter(localOneOffs[0], { + type: "contextmenu", + button: 2, + }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, timeout)); + Assert.ok( + !popupshownFired, + "popupshown should not be fired on a local one-off" + ); + + contextMenu.removeEventListener("popupshown", onPopupshown); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs_heuristicRestyle.js b/browser/components/urlbar/tests/browser/browser_oneOffs_heuristicRestyle.js new file mode 100644 index 0000000000..3513fd2dac --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_heuristicRestyle.js @@ -0,0 +1,516 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that heuristic results are updated/restyled to search results when a + * one-off is selected. + */ + +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "oneOffSearchButtons", () => { + return UrlbarTestUtils.getOneOffSearchButtons(window); +}); + +const TEST_DEFAULT_ENGINE_NAME = "Test"; + +const HISTORY_URL = "https://mozilla.org/"; + +const KEYWORD = "kw"; +const KEYWORD_URL = "https://mozilla.org/search?q=%s"; + +// Expected result data for our test results. +const RESULT_DATA_BY_TYPE = { + [UrlbarUtils.RESULT_TYPE.URL]: { + icon: `page-icon:${HISTORY_URL}`, + actionL10n: { + id: "urlbar-result-action-visit", + }, + }, + [UrlbarUtils.RESULT_TYPE.SEARCH]: { + icon: "chrome://global/skin/icons/search-glass.svg", + actionL10n: { + id: "urlbar-result-action-search-w-engine", + args: { engine: TEST_DEFAULT_ENGINE_NAME }, + }, + }, + [UrlbarUtils.RESULT_TYPE.KEYWORD]: { + icon: `page-icon:${KEYWORD_URL}`, + }, +}; + +function getSourceIcon(source) { + switch (source) { + case UrlbarUtils.RESULT_SOURCE.BOOKMARKS: + return "chrome://browser/skin/bookmark.svg"; + case UrlbarUtils.RESULT_SOURCE.HISTORY: + return "chrome://browser/skin/history.svg"; + case UrlbarUtils.RESULT_SOURCE.TABS: + return "chrome://browser/skin/tab.svg"; + default: + return null; + } +} + +/** + * Asserts that the heuristic result is *not* restyled to look like a search + * result. + * + * @param {UrlbarUtils.RESULT_TYPE} expectedType + * The expected type of the heuristic. + * @param {object} resultDetails + * The return value of UrlbarTestUtils.getDetailsOfResultAt(window, 0). + */ +async function heuristicIsNotRestyled(expectedType, resultDetails) { + Assert.equal( + resultDetails.type, + expectedType, + "The restyled result is the expected type." + ); + + Assert.equal( + resultDetails.displayed.title, + resultDetails.title, + "The displayed title is equal to the payload title." + ); + + let data = RESULT_DATA_BY_TYPE[expectedType]; + Assert.ok(data, "Sanity check: Expected type is recognized"); + + let [actionText] = data.actionL10n + ? await document.l10n.formatValues([data.actionL10n]) + : [""]; + + if ( + expectedType === UrlbarUtils.RESULT_TYPE.URL && + resultDetails.result.heuristic && + resultDetails.result.payload.title + ) { + Assert.equal( + resultDetails.displayed.url, + resultDetails.result.payload.displayUrl + ); + } else { + Assert.equal( + resultDetails.displayed.action, + actionText, + "The result has the expected non-styled action text." + ); + } + + Assert.equal( + BrowserTestUtils.is_visible(resultDetails.element.separator), + !!actionText, + "The title separator is " + (actionText ? "visible" : "hidden") + ); + Assert.equal( + BrowserTestUtils.is_visible(resultDetails.element.action), + !!actionText, + "The action text is " + (actionText ? "visible" : "hidden") + ); + + Assert.equal( + resultDetails.image, + data.icon, + "The result has the expected non-styled icon." + ); +} + +/** + * Asserts that the heuristic result is restyled to look like a search result. + * + * @param {UrlbarUtils.RESULT_TYPE} expectedType + * The expected type of the heuristic. + * @param {object} resultDetails + * The return value of UrlbarTestUtils.getDetailsOfResultAt(window, 0). + * @param {string} searchString + * The current search string. The restyled heuristic result's title is + * expected to be this string. + * @param {element} selectedOneOff + * The selected one-off button. + */ +async function heuristicIsRestyled( + expectedType, + resultDetails, + searchString, + selectedOneOff +) { + let engine = selectedOneOff.engine; + let source = selectedOneOff.source; + if (!engine && !source) { + Assert.ok(false, "An invalid one-off was passed to urlbarResultIsRestyled"); + return; + } + Assert.equal( + resultDetails.type, + expectedType, + "The restyled result is still the expected type." + ); + + let actionText; + if (engine) { + [actionText] = await document.l10n.formatValues([ + { + id: "urlbar-result-action-search-w-engine", + args: { engine: engine.name }, + }, + ]); + } else if (source) { + [actionText] = await document.l10n.formatValues([ + { + id: `urlbar-result-action-search-${UrlbarUtils.getResultSourceName( + source + )}`, + }, + ]); + } + Assert.equal( + resultDetails.displayed.action, + actionText, + "Restyled result's action text should be updated" + ); + + Assert.equal( + resultDetails.displayed.title, + searchString, + "The restyled result's title should be equal to the search string." + ); + + Assert.ok( + BrowserTestUtils.is_visible(resultDetails.element.separator), + "The restyled result's title separator should be visible" + ); + Assert.ok( + BrowserTestUtils.is_visible(resultDetails.element.action), + "The restyled result's action text should be visible" + ); + + if (engine) { + Assert.equal( + resultDetails.image, + engine.iconURI?.spec || UrlbarUtils.ICON.SEARCH_GLASS, + "The restyled result's icon should be the engine's icon." + ); + } else if (source) { + Assert.equal( + resultDetails.image, + getSourceIcon(source), + "The restyled result's icon should be the local one-off's icon." + ); + } +} + +/** + * Asserts that the specified one-off (if any) is selected and that the + * heuristic result is either restyled or not restyled as appropriate. If + * there's a selected one-off, then the heuristic is expected to be restyled; if + * there's no selected one-off, then it's expected not to be restyled. + * + * @param {string} searchString + * The current search string. If a one-off is selected, then the restyled + * heuristic result's title is expected to be this string. + * @param {UrlbarUtils.RESULT_TYPE} expectedHeuristicType + * The expected type of the heuristic. + * @param {number} expectedSelectedOneOffIndex + * The index of the expected selected one-off button. If no one-off is + * expected to be selected, then pass -1. + */ +async function assertState( + searchString, + expectedHeuristicType, + expectedSelectedOneOffIndex +) { + Assert.equal( + oneOffSearchButtons.selectedButtonIndex, + expectedSelectedOneOffIndex, + "Expected one-off should be selected" + ); + + let resultDetails = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + if (expectedSelectedOneOffIndex >= 0) { + await heuristicIsRestyled( + expectedHeuristicType, + resultDetails, + searchString, + oneOffSearchButtons.selectedButton + ); + } else { + await heuristicIsNotRestyled(expectedHeuristicType, resultDetails); + } +} + +add_setup(async function () { + await SearchTestUtils.installSearchExtension( + { + name: TEST_DEFAULT_ENGINE_NAME, + keyword: "@test", + }, + { setAsDefault: true } + ); + let engine = Services.search.getEngineByName(TEST_DEFAULT_ENGINE_NAME); + await Services.search.moveEngine(engine, 0); + + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(HISTORY_URL); + } + + await PlacesUtils.keywords.insert({ + keyword: KEYWORD, + url: KEYWORD_URL, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.keywords.remove(KEYWORD); + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + + // Move the mouse away from the view so that a result or one-off isn't + // inadvertently highlighted. See bug 1659011. + EventUtils.synthesizeMouse( + gURLBar.inputField, + 0, + 0, + { type: "mousemove" }, + window + ); +}); + +add_task(async function arrow_engine_url() { + await doArrowTest("mozilla.or", UrlbarUtils.RESULT_TYPE.URL, false); +}); + +add_task(async function arrow_engine_search() { + await doArrowTest("test", UrlbarUtils.RESULT_TYPE.SEARCH, false); +}); + +add_task(async function arrow_engine_keyword() { + await doArrowTest(`${KEYWORD} test`, UrlbarUtils.RESULT_TYPE.KEYWORD, false); +}); + +add_task(async function arrow_local_url() { + await doArrowTest("mozilla.or", UrlbarUtils.RESULT_TYPE.URL, true); +}); + +add_task(async function arrow_local_search() { + await doArrowTest("test", UrlbarUtils.RESULT_TYPE.SEARCH, true); +}); + +add_task(async function arrow_local_keyword() { + await doArrowTest(`${KEYWORD} test`, UrlbarUtils.RESULT_TYPE.KEYWORD, true); +}); + +/** + * Arrows down to the one-offs, checks the heuristic, and clicks it. + * + * @param {string} searchString + * The search string to use. + * @param {UrlbarUtils.RESULT_TYPE} expectedHeuristicType + * The type of heuristic result that the search string is expected to trigger. + * @param {boolean} useLocal + * Whether to test a local one-off or an engine one-off. If true, test a + * local one-off. If false, test an engine one-off. + */ +async function doArrowTest(searchString, expectedHeuristicType, useLocal) { + await doTest(searchString, expectedHeuristicType, useLocal, async () => { + info( + "Arrow down to the one-offs, observe heuristic is restyled as a search result." + ); + let resultCount = UrlbarTestUtils.getResultCount(window); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: resultCount }); + await searchPromise; + await assertState(searchString, expectedHeuristicType, 0); + + let depth = 1; + if (useLocal) { + for (; !oneOffSearchButtons.selectedButton.source; depth++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.ok( + oneOffSearchButtons.selectedButton.source, + "Selected one-off is local" + ); + await assertState(searchString, expectedHeuristicType, depth - 1); + } + + info( + "Arrow up out of the one-offs, observe heuristic styling is restored." + ); + EventUtils.synthesizeKey("KEY_ArrowUp", { repeat: depth }); + await assertState(searchString, expectedHeuristicType, -1); + + info( + "Arrow back down into the one-offs, observe heuristic is restyled as a search result." + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: depth }); + await assertState(searchString, expectedHeuristicType, depth - 1); + }); +} + +add_task(async function altArrow_engine_url() { + await doAltArrowTest("mozilla.or", UrlbarUtils.RESULT_TYPE.URL, false); +}); + +add_task(async function altArrow_engine_search() { + await doAltArrowTest("test", UrlbarUtils.RESULT_TYPE.SEARCH, false); +}); + +add_task(async function altArrow_engine_keyword() { + await doAltArrowTest( + `${KEYWORD} test`, + UrlbarUtils.RESULT_TYPE.KEYWORD, + false + ); +}); + +add_task(async function altArrow_local_url() { + await doAltArrowTest("mozilla.or", UrlbarUtils.RESULT_TYPE.URL, true); +}); + +add_task(async function altArrow_local_search() { + await doAltArrowTest("test", UrlbarUtils.RESULT_TYPE.SEARCH, true); +}); + +add_task(async function altArrow_local_keyword() { + await doAltArrowTest( + `${KEYWORD} test`, + UrlbarUtils.RESULT_TYPE.KEYWORD, + true + ); +}); + +/** + * Alt-arrows down to the one-offs so that the heuristic remains selected, + * checks the heuristic, and clicks it. + * + * @param {string} searchString + * The search string to use. + * @param {UrlbarUtils.RESULT_TYPE} expectedHeuristicType + * The type of heuristic result that the search string is expected to trigger. + * @param {boolean} useLocal + * Whether to test a local one-off or an engine one-off. If true, test a + * local one-off. If false, test an engine one-off. + */ +async function doAltArrowTest(searchString, expectedHeuristicType, useLocal) { + await doTest(searchString, expectedHeuristicType, useLocal, async () => { + info( + "Alt+down into the one-offs, observe heuristic is restyled as a search result." + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await searchPromise; + await assertState(searchString, expectedHeuristicType, 0); + + let depth = 1; + if (useLocal) { + for (; !oneOffSearchButtons.selectedButton.source; depth++) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + Assert.ok( + oneOffSearchButtons.selectedButton.source, + "Selected one-off is local" + ); + await assertState(searchString, expectedHeuristicType, depth - 1); + } + + info( + "Arrow down and then up to re-select the heuristic, observe its styling is restored." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + await assertState(searchString, expectedHeuristicType, -1); + + info( + "Alt+down into the one-offs, observe the heuristic is restyled as a search result." + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: depth }); + await assertState(searchString, expectedHeuristicType, depth - 1); + + info("Alt+up out of the one-offs, observe the heuristic is restored."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true, repeat: depth }); + await assertState(searchString, expectedHeuristicType, -1); + + info( + "Alt+down into the one-offs, observe the heuristic is restyled as a search result." + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: depth }); + await assertState(searchString, expectedHeuristicType, depth - 1); + }); +} + +/** + * The main test function. Starts a search, asserts that the heuristic has the + * expected type, calls a callback to run more checks, and then finally clicks + * the restyled heuristic to make sure search mode is confirmed. + * + * @param {string} searchString + * The search string to use. + * @param {UrlbarUtils.RESULT_TYPE} expectedHeuristicType + * The type of heuristic result that the search string is expected to trigger. + * @param {boolean} useLocal + * Whether to test a local one-off or an engine one-off. If true, test a + * local one-off. If false, test an engine one-off. + * @param {Function} callback + * This is called after the search completes. It should perform whatever + * checks are necessary for the test task. Important: When it returns, it + * should make sure that the first one-off is selected. + */ +async function doTest(searchString, expectedHeuristicType, useLocal, callback) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + await TestUtils.waitForCondition( + () => !oneOffSearchButtons._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic, "First result is heuristic"); + Assert.equal( + result.type, + expectedHeuristicType, + "Heuristic is expected type" + ); + await assertState(searchString, expectedHeuristicType, -1); + + await callback(); + + Assert.ok( + oneOffSearchButtons.selectedButton, + "The callback should leave a one-off selected so that the heuristic remains re-styled" + ); + + info("Click the heuristic result and observe it confirms search mode."); + let selectedButton = oneOffSearchButtons.selectedButton; + let expectedSearchMode = { + entry: "oneoff", + isPreview: true, + }; + if (useLocal) { + expectedSearchMode.source = selectedButton.source; + } else { + expectedSearchMode.engineName = selectedButton.engine.name; + } + + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + let heuristicRow = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 0 + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(heuristicRow, {}); + await searchPromise; + + expectedSearchMode.isPreview = false; + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +} diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs_keyModifiers.js b/browser/components/urlbar/tests/browser/browser_oneOffs_keyModifiers.js new file mode 100644 index 0000000000..30b241b3b3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_keyModifiers.js @@ -0,0 +1,392 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that one-offs behave differently with key modifiers. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const SEARCH_STRING = "foo.bar"; + +XPCOMUtils.defineLazyGetter(this, "oneOffSearchButtons", () => { + return UrlbarTestUtils.getOneOffSearchButtons(window); +}); + +let engine; + +async function searchAndOpenPopup(value) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + fireInputEvent: true, + }); + await TestUtils.waitForCondition( + () => !oneOffSearchButtons._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); +} + +add_setup(async function () { + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + await Services.search.moveEngine(engine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + // Initialize history with enough visits to fill up the view. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + for (let i = 0; i < maxResults; i++) { + await PlacesTestUtils.addVisits( + "http://mochi.test:8888/browser_urlbarOneOffs.js/?" + i + ); + } + + // Add some more visits to the last URL added above so that the top-sites view + // will be non-empty. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits( + "http://mochi.test:8888/browser_urlbarOneOffs.js/?" + (maxResults - 1) + ); + } + await updateTopSites(sites => { + return ( + sites && sites[0] && sites[0].url.startsWith("http://mochi.test:8888/") + ); + }); + + // Move the mouse away from the view so that a result or one-off isn't + // inadvertently highlighted. See bug 1659011. + EventUtils.synthesizeMouse( + gURLBar.inputField, + 0, + 0, + { type: "mousemove" }, + window + ); +}); + +// Shift clicking with no search string should open search mode, like an +// unmodified click. +add_task(async function shift_click_empty() { + await searchAndOpenPopup(""); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(oneOffs[0], { shiftKey: true }); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Shift clicking with a search string should perform a search in the current +// tab. +add_task(async function shift_click_search() { + await searchAndOpenPopup(SEARCH_STRING); + let resultsPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://mochi.test:8888/?terms=foo.bar" + ); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + EventUtils.synthesizeMouseAtCenter(oneOffs[0], { shiftKey: true }); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Pressing Shift+Enter on a one-off with no search string should open search +// mode, like an unmodified click. +add_task(async function shift_enter_empty() { + await searchAndOpenPopup(""); + // Alt+Down to select the first one-off. + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter", { shiftKey: true }); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Pressing Shift+Enter on a one-off with a search string should perform a +// search in the current tab. +add_task(async function shift_enter_search() { + await searchAndOpenPopup(SEARCH_STRING); + let resultsPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://mochi.test:8888/?terms=foo.bar" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_Enter", { shiftKey: true }); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Pressing Alt+Enter on a one-off on an "empty" page (e.g. new tab) should open +// search mode in the current tab. +add_task(async function alt_enter_emptypage() { + await BrowserTestUtils.withNewTab("about:home", async function (browser) { + await searchAndOpenPopup(SEARCH_STRING); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + // Alt+Down to select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + await searchPromise; + Assert.equal( + browser, + gBrowser.selectedBrowser, + "The foreground tab hasn't changed." + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Pressing Alt+Enter on a one-off with no search string and on a "non-empty" +// page should open search mode in a new foreground tab. +add_task(async function alt_enter_empty() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await searchAndOpenPopup(""); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + // Alt+Down to select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + await tabOpenPromise; + Assert.notEqual( + browser, + gBrowser.selectedBrowser, + "The current foreground tab is new." + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Assert.equal( + browser, + gBrowser.selectedBrowser, + "We're back in the original tab." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Pressing Alt+Enter on a remote one-off with a search string and on a +// "non-empty" page should perform a search in a new foreground tab. +add_task(async function alt_enter_search_remote() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await searchAndOpenPopup(SEARCH_STRING); + // Alt+Down to select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "http://mochi.test:8888/?terms=foo.bar", + true + ); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + // This implictly checks the correct page is loaded. + let newTab = await tabOpenPromise; + Assert.equal( + newTab, + gBrowser.selectedTab, + "The current foreground tab is new." + ); + // Check search mode is not activated in the new tab. + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(newTab); + Assert.equal( + browser, + gBrowser.selectedBrowser, + "We're back in the original tab." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Pressing Alt+Enter on a local one-off with a search string and on a +// "non-empty" page should open search mode in a new foreground tab with the +// search string already populated. +add_task(async function alt_enter_search_local() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await searchAndOpenPopup(SEARCH_STRING); + // Alt+Down to select the first local one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + while ( + oneOffSearchButtons.selectedButton.id != + "urlbar-engine-one-off-item-bookmarks" + ) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + await tabOpenPromise; + Assert.notEqual( + browser, + gBrowser.selectedBrowser, + "The current foreground tab is new." + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + SEARCH_STRING, + "The search term was duplicated to the new tab." + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + Assert.equal( + browser, + gBrowser.selectedBrowser, + "We're back in the original tab." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +// Accel+Clicking a one-off with an empty search string should open search mode +// in a new background tab. +add_task(async function accel_click_empty() { + await searchAndOpenPopup(""); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + + // We have to listen for the new tab using this brute force method. + // about:newtab is preloaded in the background. When about:newtab is opened, + // the cached version is shown. Since the page is already loaded, + // waitForNewTab does not detect it. It also doesn't fire the TabOpen event. + let tabCount = gBrowser.tabs.length; + let tabOpenPromise = TestUtils.waitForCondition( + () => + gBrowser.tabs.length == tabCount + 1 + ? gBrowser.tabs[gBrowser.tabs.length - 1] + : false, + "Waiting for background about:newtab to open." + ); + EventUtils.synthesizeMouseAtCenter(oneOffs[0], { accelKey: true }); + let newTab = await tabOpenPromise; + Assert.notEqual( + newTab.linkedBrowser, + gBrowser.selectedBrowser, + "The foreground tab hasn't changed." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.switchTab(gBrowser, newTab); + // Check the new background tab is already in search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + BrowserTestUtils.removeTab(newTab); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Accel+Clicking a remote one-off with a search string should execute a search +// in a new background tab. +add_task(async function accel_click_search_remote() { + await searchAndOpenPopup(SEARCH_STRING); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "http://mochi.test:8888/?terms=foo.bar", + true + ); + EventUtils.synthesizeMouseAtCenter(oneOffs[0], { accelKey: true }); + // This implictly checks the correct page is loaded. + let newTab = await tabOpenPromise; + Assert.notEqual( + gBrowser.selectedTab, + newTab, + "The foreground tab hasn't changed." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + // Switch to the background tab, which is the last tab in gBrowser.tabs. + BrowserTestUtils.switchTab(gBrowser, newTab); + // Check the new background tab is not search mode. + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(newTab); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Accel+Clicking a local one-off with a search string should open search mode +// in a new background tab with the search string already populated. +add_task(async function accel_click_search_local() { + await searchAndOpenPopup(SEARCH_STRING); + let oneOffs = oneOffSearchButtons.getSelectableButtons(true); + let oneOff; + for (oneOff of oneOffs) { + if (oneOff.id == "urlbar-engine-one-off-item-bookmarks") { + break; + } + } + let tabCount = gBrowser.tabs.length; + let tabOpenPromise = TestUtils.waitForCondition( + () => + gBrowser.tabs.length == tabCount + 1 + ? gBrowser.tabs[gBrowser.tabs.length - 1] + : false, + "Waiting for background about:newtab to open." + ); + EventUtils.synthesizeMouseAtCenter(oneOff, { accelKey: true }); + let newTab = await tabOpenPromise; + Assert.notEqual( + newTab.linkedBrowser, + gBrowser.selectedBrowser, + "The foreground tab hasn't changed." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.switchTab(gBrowser, newTab); + // Check the new background tab is already in search mode. + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + // Check the search string is already populated. + Assert.equal( + gURLBar.value, + SEARCH_STRING, + "The search term was duplicated to the new tab." + ); + BrowserTestUtils.removeTab(newTab); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs_searchSuggestions.js b/browser/components/urlbar/tests/browser/browser_oneOffs_searchSuggestions.js new file mode 100644 index 0000000000..ef324b08cd --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_searchSuggestions.js @@ -0,0 +1,358 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests various actions relating to search suggestions and the one-off buttons. + */ + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const TEST_ENGINE2_BASENAME = "searchSuggestionEngine2.xml"; + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +var gEngine; +var gEngine2; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + gEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + gEngine2 = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE2_BASENAME, + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.moveEngine(gEngine2, 0); + await Services.search.moveEngine(gEngine, 0); + await Services.search.setDefault( + gEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + registerCleanupFunction(async function () { + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +async function withSuggestions(testFn) { + // First run with remote suggestions, and then run with form history. + await withSuggestionOnce(false, testFn); + await withSuggestionOnce(true, testFn); +} + +async function withSuggestionOnce(useFormHistory, testFn) { + if (useFormHistory) { + // Add foofoo twice so it's more frecent so it appears first so that the + // order of form history results matches the order of remote suggestion + // results. + await UrlbarTestUtils.formHistory.add(["foofoo", "foofoo", "foobar"]); + } + await BrowserTestUtils.withNewTab(gBrowser, async () => { + let value = "foo"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + fireInputEvent: true, + }); + let index = await UrlbarTestUtils.promiseSuggestionsPresent(window); + await assertState({ + inputValue: value, + resultIndex: 0, + }); + await withHttpServer(serverInfo, () => { + return testFn(index, useFormHistory); + }); + }); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); +} + +async function selectSecondSuggestion(index, isFormHistory) { + // Down to select the first search suggestion. + for (let i = index; i > 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await assertState({ + inputValue: "foofoo", + resultIndex: index, + suggestion: { + isFormHistory, + }, + }); + + // Down to select the next search suggestion. + EventUtils.synthesizeKey("KEY_ArrowDown"); + await assertState({ + inputValue: "foobar", + resultIndex: index + 1, + suggestion: { + isFormHistory, + }, + }); +} + +// Presses the Return key when a one-off is selected after selecting a search +// suggestion. +add_task(async function test_returnAfterSuggestion() { + await withSuggestions(async (index, usingFormHistory) => { + await selectSecondSuggestion(index, usingFormHistory); + + // Alt+Down to select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await assertState({ + inputValue: "foobar", + resultIndex: index + 1, + oneOffIndex: 0, + suggestion: { + isFormHistory: usingFormHistory, + }, + }); + + let heuristicResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + !BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should not be visible" + ); + + let resultsPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gEngine.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + }); +}); + +// Presses the Return key when a non-default one-off is selected after selecting +// a search suggestion. +add_task(async function test_returnAfterSuggestion_nonDefault() { + await withSuggestions(async (index, usingFormHistory) => { + await selectSecondSuggestion(index, usingFormHistory); + + // Alt+Down twice to select the second one-off. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await assertState({ + inputValue: "foobar", + resultIndex: index + 1, + oneOffIndex: 1, + suggestion: { + isFormHistory: usingFormHistory, + }, + }); + + let resultsPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gEngine2.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + }); +}); + +// Clicks a one-off engine after selecting a search suggestion. +add_task(async function test_clickAfterSuggestion() { + await withSuggestions(async (index, usingFormHistory) => { + await selectSecondSuggestion(index, usingFormHistory); + + let oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + let resultsPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(oneOffs[1], {}); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gEngine2.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + }); +}); + +// Clicks a non-default one-off engine after selecting a search suggestion. +add_task(async function test_clickAfterSuggestion_nonDefault() { + await withSuggestions(async (index, usingFormHistory) => { + await selectSecondSuggestion(index, usingFormHistory); + + let oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + let resultsPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(oneOffs[1], {}); + await resultsPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gEngine2.name, + entry: "oneoff", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + }); +}); + +// Selects a non-default one-off engine and then clicks a search suggestion. +add_task(async function test_selectOneOffThenSuggestion() { + await withSuggestions(async (index, usingFormHistory) => { + // Select a non-default one-off engine. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await assertState({ + inputValue: "foo", + resultIndex: 0, + oneOffIndex: 1, + }); + + let heuristicResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + BrowserTestUtils.is_visible(heuristicResult.element.action), + "The heuristic action should be visible because the result is selected" + ); + + // Now click the second suggestion. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index + 1); + // Note search history results don't change their engine when the selected + // one-off button changes! + let resultsPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + usingFormHistory + ? `http://mochi.test:8888/?terms=foobar` + : `http://localhost:20709/?terms=foobar` + ); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + await resultsPromise; + }); +}); + +add_task(async function overridden_engine_not_reused() { + info( + "An overridden search suggestion item should not be reused by a search with another engine" + ); + await BrowserTestUtils.withNewTab(gBrowser, async () => { + let typedValue = "foo"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + let index = await UrlbarTestUtils.promiseSuggestionsPresent(window); + // Down to select the first search suggestion. + for (let i = index; i > 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await assertState({ + inputValue: "foofoo", + resultIndex: index, + suggestion: { + isFormHistory: false, + }, + }); + + // ALT+Down to select the second search engine. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await assertState({ + inputValue: "foofoo", + resultIndex: index, + oneOffIndex: 1, + suggestion: { + isFormHistory: false, + }, + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let label = result.displayed.action; + // Run again the query, check the label has been replaced. + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typedValue, + fireInputEvent: true, + }); + index = await UrlbarTestUtils.promiseSuggestionsPresent(window); + await assertState({ + inputValue: "foo", + resultIndex: 0, + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.notEqual( + result.displayed.action, + label, + "The label should have been updated" + ); + }); +}); + +async function assertState({ + resultIndex, + inputValue, + oneOffIndex = -1, + suggestion = null, +}) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + resultIndex, + "Expected result should be selected" + ); + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButtonIndex, + oneOffIndex, + "Expected one-off should be selected" + ); + if (inputValue !== undefined) { + Assert.equal(gURLBar.value, inputValue, "Expected input value"); + } + + if (suggestion) { + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result type should be SEARCH" + ); + if (suggestion.isFormHistory) { + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Result source should be HISTORY" + ); + } else { + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.SEARCH, + "Result source should be SEARCH" + ); + } + Assert.equal( + typeof result.searchParams.suggestion, + "string", + "Result should have a suggestion" + ); + Assert.equal( + result.searchParams.suggestion, + suggestion.value || inputValue, + "Result should have the expected suggestion" + ); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_oneOffs_settings.js b/browser/components/urlbar/tests/browser/browser_oneOffs_settings.js new file mode 100644 index 0000000000..b4b1e7006e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_settings.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This tests that the settings button in the one-off buttons display correctly + * loads the search preferences. + */ + +let gMaxResults; + +add_setup(async function () { + gMaxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + + let visits = []; + for (let i = 0; i < gMaxResults; i++) { + visits.push({ + uri: makeURI("http://example.com/browser_urlbarOneOffs.js/?" + i), + // TYPED so that the visit shows up when the urlbar's drop-down arrow is + // pressed. + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(visits); +}); + +async function selectSettings(win, activateFn) { + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "about:blank" }, + async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "example.com", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(win, gMaxResults - 1); + + await UrlbarTestUtils.promisePopupClose(win, async () => { + let prefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + + activateFn(); + + await prefPaneLoaded; + }); + + Assert.equal( + win.gBrowser.contentWindow.history.state, + "paneSearch", + "Should have opened the search preferences pane" + ); + } + ); +} + +add_task(async function test_open_settings_with_enter() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + await selectSettings(win, () => { + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + + Assert.ok( + UrlbarTestUtils.getOneOffSearchButtons( + win + ).selectedButton.classList.contains("search-setting-button"), + "Should have selected the settings button" + ); + + EventUtils.synthesizeKey("KEY_Enter", {}, win); + }); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_open_settings_with_click() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + await selectSettings(win, () => { + UrlbarTestUtils.getOneOffSearchButtons(win).settingsButton.click(); + }); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_pasteAndGo.js b/browser/components/urlbar/tests/browser/browser_pasteAndGo.js new file mode 100644 index 0000000000..8d2a27afc3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_pasteAndGo.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for the paste and go functionality of the urlbar. + */ + +add_task(async function () { + const kURLs = [ + "http://example.com/1", + "http://example.org/2\n", + "http://\nexample.com/3\n", + ]; + for (let url of kURLs) { + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + gURLBar.focus(); + + await SimpleTest.promiseClipboardChange(url, () => { + clipboardHelper.copyString(url); + }); + let menuitem = await promiseContextualMenuitem("paste-and-go"); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + browser, + false, + url.replace(/\n/g, "") + ); + menuitem.closest("menupopup").activateItem(menuitem); + // Using toSource in order to get the newlines escaped: + info("Paste and go, loading " + url.toSource()); + await browserLoadedPromise; + ok(true, "Successfully loaded " + url); + }); + } +}); + +add_task(async function test_invisible_char() { + const url = "http://example.com/4\u2028"; + await BrowserTestUtils.withNewTab("about:blank", async function (browser) { + gURLBar.focus(); + await SimpleTest.promiseClipboardChange(url, () => { + clipboardHelper.copyString(url); + }); + let menuitem = await promiseContextualMenuitem("paste-and-go"); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + browser, + false, + url.replace(/\u2028/g, "") + ); + menuitem.closest("menupopup").activateItem(menuitem); + // Using toSource in order to get the newlines escaped: + info("Paste and go, loading " + url.toSource()); + await browserLoadedPromise; + ok(true, "Successfully loaded " + url); + }); +}); + +add_task(async function test_with_input_and_results() { + // Test paste and go When there's some input and the results pane is open. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + const url = "http://example.com/"; + await SimpleTest.promiseClipboardChange(url, () => { + clipboardHelper.copyString(url); + }); + let menuitem = await promiseContextualMenuitem("paste-and-go"); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + url + ); + menuitem.closest("menupopup").activateItem(menuitem); + // Using toSource in order to get the newlines escaped: + info("Paste and go, loading " + url.toSource()); + await browserLoadedPromise; + ok(true, "Successfully loaded " + url); +}); diff --git a/browser/components/urlbar/tests/browser/browser_paste_multi_lines.js b/browser/components/urlbar/tests/browser/browser_paste_multi_lines.js new file mode 100644 index 0000000000..8c4be18a7b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_paste_multi_lines.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test handling whitespace chars such as "\n”. + +const TEST_DATA = [ + { + input: "this is a\ntest", + expected: { + urlbar: "this is a test", + autocomplete: "this is a test", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, + { + input: "this is a\n\ttest", + expected: { + urlbar: "this is a test", + autocomplete: "this is a test", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, + { + input: "http:\n//\nexample.\ncom", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "htp:example.\ncom", + expected: { + urlbar: "htp:example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "example.\ncom", + expected: { + urlbar: "example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com/foo bar/", + expected: { + urlbar: "http://example.com/foo bar/", + autocomplete: "http://example.com/foo bar/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://exam\nple.com/foo bar/", + expected: { + urlbar: "http://example.com/foo bar/", + autocomplete: "http://example.com/foo bar/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "javasc\nript:\nalert(1)", + expected: { + urlbar: "alert(1)", + autocomplete: "alert(1)", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, + { + input: "a\nb\nc", + expected: { + urlbar: "a b c", + autocomplete: "a b c", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, + { + input: "lo\ncal\nhost", + expected: { + urlbar: "localhost", + autocomplete: "http://localhost/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "data:text/html,<iframe\n src='example\n.com'>\n</iframe>", + expected: { + urlbar: "data:text/html,<iframe src='example .com'> </iframe>", + autocomplete: "data:text/html,<iframe src='example .com'> </iframe>", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "data:,123\n4 5\n6", + expected: { + urlbar: "data:,123 4 5 6", + autocomplete: "data:,123 4 5 6", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "data:text/html;base64,123\n4 5\n6", + expected: { + urlbar: "data:text/html;base64,1234 56", + autocomplete: "data:text/html;base64,123456", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com\n", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com\r", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://ex\ra\nmp\r\nle.com\r\n", + expected: { + urlbar: "http://example.com", + autocomplete: "http://example.com/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "http://example.com/titled", + expected: { + urlbar: "http://example.com/titled", + autocomplete: "example title", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "127.0.0.1\r", + expected: { + urlbar: "127.0.0.1", + autocomplete: "http://127.0.0.1/", + type: UrlbarUtils.RESULT_TYPE.URL, + }, + }, + { + input: "\r\n\r\n\r\n\r\n\r\n", + expected: { + urlbar: "", + autocomplete: "", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }, + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // There are cases that URLBar loses focus before assertion of this test. + // In that case, this test will be failed since the result is closed + // before it. We use this pref so that keep the result even if lose focus. + ["ui.popup.disable_autohide", true], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits({ + uri: "http://example.com/titled", + title: "example title", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function test_paste_onto_urlbar() { + for (const { input, expected } of TEST_DATA) { + gURLBar.value = ""; + gURLBar.focus(); + + await paste(input); + await assertResult(expected); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +add_task(async function test_paste_after_opening_autocomplete_panel() { + for (const { input, expected } of TEST_DATA) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + await paste(input); + await assertResult(expected); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +async function assertResult(expected) { + Assert.equal(gURLBar.value, expected.urlbar, "Pasted value is correct"); + + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.title, + expected.autocomplete, + "Title of autocomplete is correct" + ); + Assert.equal(result.type, expected.type, "Type of autocomplete is correct"); + + if (gURLBar.value) { + Assert.equal(gURLBar.getAttribute("usertyping"), "true"); + Assert.ok(BrowserTestUtils.is_visible(gURLBar.goButton)); + } else { + Assert.ok(!gURLBar.hasAttribute("usertyping")); + Assert.ok(BrowserTestUtils.is_hidden(gURLBar.goButton)); + } +} + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input.replace(/\r\n?/g, "\n"), () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_paste_then_focus.js b/browser/components/urlbar/tests/browser/browser_paste_then_focus.js new file mode 100644 index 0000000000..23d603fd80 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_paste_then_focus.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the urlbar value when focusing after pasting value. + +const TEST_DATA = [ + { + input: "this is a\ntest", + expected: "this is a test", + }, + { + input: "http:\n//\nexample.\ncom", + expected: "http://example.com", + }, + { + input: "javasc\nript:\nalert(1)", + expected: "alert(1)", + }, + { + input: "javascript:alert(1)", + expected: "alert(1)", + }, + { + input: "test", + expected: "test", + }, +]; + +add_task(async function test_paste_then_focus() { + for (const testData of TEST_DATA) { + gURLBar.value = ""; + gURLBar.focus(); + + EventUtils.synthesizeKey("x"); + gURLBar.select(); + + await paste(testData.input); + + gURLBar.blur(); + gURLBar.focus(); + + Assert.equal( + gURLBar.value, + testData.expected, + "Value on urlbar is correct" + ); + } +}); + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input, () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js b/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js new file mode 100644 index 0000000000..883b128c60 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_paste_then_switch_tab.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the urlbar value when switching tab after pasting value. + +const TEST_DATA = [ + { + input: "this is a\ntest", + expected: "this is a test", + }, + { + input: "https:\n//\nexample.\ncom", + expected: "https://example.com", + }, + { + input: "http:\n//\nexample.\ncom", + expected: "example.com", + }, + { + input: "javasc\nript:\nalert(1)", + expected: "alert(1)", + }, + { + input: "javascript:alert(1)", + expected: "alert(1)", + }, + { + // Has U+3000 IDEOGRAPHIC SPACE. + input: "Mozilla Firefox", + expected: "Mozilla Firefox", + }, + { + input: "test", + expected: "test", + }, +]; + +add_task(async function test_paste_then_switch_tab() { + for (const testData of TEST_DATA) { + gURLBar.focus(); + gURLBar.select(); + + await paste(testData.input); + + // Switch to a new tab. + const originalTab = gBrowser.selectedTab; + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.waitForCondition(() => !gURLBar.value); + + // Switch back to original tab. + gBrowser.selectedTab = originalTab; + + Assert.equal( + gURLBar.value, + testData.expected, + "Value on urlbar is correct" + ); + + BrowserTestUtils.removeTab(newTab); + } +}); + +async function paste(input) { + await SimpleTest.promiseClipboardChange(input, () => { + clipboardHelper.copyString(input); + }); + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} diff --git a/browser/components/urlbar/tests/browser/browser_percent_encoded.js b/browser/components/urlbar/tests/browser/browser_percent_encoded.js new file mode 100644 index 0000000000..c334c03a09 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_percent_encoded.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests that searching history works for both encoded or decoded strings. + +add_task(async function test() { + const decoded = "日本"; + const TEST_URL = TEST_BASE_URL + "?" + decoded; + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); + + // Visit url in a new tab, going through normal urlbar workflow. + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + let promise = PlacesTestUtils.waitForNotification("page-visited", visits => { + Assert.equal( + visits.length, + 1, + "Was notified for the right number of visits." + ); + let { url, transitionType } = visits[0]; + return ( + url == encodeURI(TEST_URL) && + transitionType == PlacesUtils.history.TRANSITIONS.TYPED + ); + }); + gURLBar.focus(); + gURLBar.value = TEST_URL; + info("Visiting url"); + EventUtils.synthesizeKey("KEY_Enter"); + await promise; + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + + info("Search for the decoded string."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: decoded, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check number of results" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, encodeURI(TEST_URL), "Check result url"); + + info("Search for the encoded string."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: encodeURIComponent(decoded), + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Check number of results" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, encodeURI(TEST_URL), "Check result url"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_placeholder.js b/browser/components/urlbar/tests/browser/browser_placeholder.js new file mode 100644 index 0000000000..fbf0a5007c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_placeholder.js @@ -0,0 +1,412 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures the placeholder is set correctly for different search + * engines. + */ + +"use strict"; + +var originalEngine, extraEngine, extraPrivateEngine, expectedString; +var tabs = []; + +var noEngineString; + +add_setup(async function () { + originalEngine = await Services.search.getDefault(); + [noEngineString, expectedString] = ( + await document.l10n.formatMessages([ + { id: "urlbar-placeholder" }, + { + id: "urlbar-placeholder-with-name", + args: { name: originalEngine.name }, + }, + ]) + ).map(msg => msg.attributes[0].value); + + let rootUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://mochi.test:8888/" + ); + await SearchTestUtils.installSearchExtension({ + name: "extraEngine", + search_url: "https://mochi.test:8888/", + suggest_url: `${rootUrl}/searchSuggestionEngine.sjs`, + }); + extraEngine = Services.search.getEngineByName("extraEngine"); + await SearchTestUtils.installSearchExtension({ + name: "extraPrivateEngine", + search_url: "https://mochi.test:8888/", + suggest_url: `${rootUrl}/searchSuggestionEngine.sjs`, + }); + extraPrivateEngine = Services.search.getEngineByName("extraPrivateEngine"); + + // Force display of a tab with a URL bar, to clear out any possible placeholder + // initialization listeners that happen on startup. + let urlTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + BrowserTestUtils.removeTab(urlTab); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + }); +}); + +add_task(async function test_change_default_engine_updates_placeholder() { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(gURLBar.placeholder, expectedString); +}); + +add_task(async function test_delayed_update_placeholder() { + // We remove the change of engine listener here as that is set so that + // if the engine is changed by the user then the placeholder is always updated + // straight away. As we want to test the delay update here, we remove the + // listener and call the placeholder update manually with the delay flag. + Services.obs.removeObserver(BrowserSearch, "browser-search-engine-modified"); + + // Since we can't easily test for startup changes, we'll at least test the delay + // of update for the placeholder works. + let urlTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + tabs.push(urlTab); + + // Open a tab with a blank URL bar. + let blankTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + tabs.push(blankTab); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // Pretend we've "initialized". + BrowserSearch._updateURLBarPlaceholder(extraEngine.name, false, true); + + Assert.equal( + gURLBar.placeholder, + expectedString, + "Placeholder should be unchanged." + ); + + // Now switch to a tab with something in the URL Bar. + await BrowserTestUtils.switchTab(gBrowser, urlTab); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should have updated in the background." + ); + + // Do it the other way to check both named engine and fallback code paths. + await BrowserTestUtils.switchTab(gBrowser, blankTab); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserSearch._updateURLBarPlaceholder(originalEngine.name, false, true); + + Assert.equal( + gURLBar.placeholder, + noEngineString, + "Placeholder should be unchanged." + ); + Assert.deepEqual( + document.l10n.getAttributes(gURLBar.inputField), + { id: "urlbar-placeholder", args: null }, + "Placeholder data should be unchanged." + ); + + await BrowserTestUtils.switchTab(gBrowser, urlTab); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + // Now check when we have a URL displayed, the placeholder is updated straight away. + BrowserSearch._updateURLBarPlaceholder(extraEngine.name, false); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should go back to the default" + ); + Assert.equal( + gURLBar.placeholder, + noEngineString, + "Placeholder should be the default." + ); + + Services.obs.addObserver(BrowserSearch, "browser-search-engine-modified"); +}); + +add_task(async function test_private_window_no_separate_engine() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, expectedString); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_private_window_separate_engine() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault", true]], + }); + const originalPrivateEngine = await Services.search.getDefaultPrivate(); + registerCleanupFunction(async () => { + await Services.search.setDefaultPrivate( + originalPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + // Keep the normal default as a different string to the private, so that we + // can be sure we're testing the right thing. + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + extraPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, noEngineString); + + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + Assert.equal(win.gURLBar.placeholder, expectedString); + + await BrowserTestUtils.closeWindow(win); + + // Verify that the placeholder for private windows is updated even when no + // private window is visible (https://bugzilla.mozilla.org/1792816). + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + extraPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + const win2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + Assert.equal(win2.gURLBar.placeholder, noEngineString); + await BrowserTestUtils.closeWindow(win2); + + // And ensure this doesn't affect the placeholder for non private windows. + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + Assert.equal(win.gURLBar.placeholder, expectedString); +}); + +add_task(async function test_search_mode_engine_web() { + // Add our test engine to WEB_ENGINE_NAMES so that it's recognized as a web + // engine. + SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add( + extraEngine.wrappedJSObject._extensionID + ); + + await doSearchModeTest( + { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: extraEngine.name, + }, + { + id: "urlbar-placeholder-search-mode-web-2", + args: { name: extraEngine.name }, + } + ); + + SearchUtils.GENERAL_SEARCH_ENGINE_IDS.delete( + extraEngine.wrappedJSObject._extensionID + ); +}); + +add_task(async function test_search_mode_engine_other() { + await doSearchModeTest( + { engineName: extraEngine.name }, + { + id: "urlbar-placeholder-search-mode-other-engine", + args: { name: extraEngine.name }, + } + ); +}); + +add_task(async function test_search_mode_bookmarks() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS }, + { id: "urlbar-placeholder-search-mode-other-bookmarks", args: null } + ); +}); + +add_task(async function test_search_mode_tabs() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.TABS }, + { id: "urlbar-placeholder-search-mode-other-tabs", args: null } + ); +}); + +add_task(async function test_search_mode_history() { + await doSearchModeTest( + { source: UrlbarUtils.RESULT_SOURCE.HISTORY }, + { id: "urlbar-placeholder-search-mode-other-history", args: null } + ); +}); + +add_task(async function test_change_default_engine_updates_placeholder() { + tabs.push(await BrowserTestUtils.openNewForegroundTab(gBrowser)); + + info(`Set engine to ${extraEngine.name}`); + await Services.search.setDefault( + extraEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should match the default placeholder for non-built-in engines." + ); + Assert.equal(gURLBar.placeholder, noEngineString); + + info(`Set engine to ${originalEngine.name}`); + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + // Simulate the placeholder not having changed due to the delayed update + // on startup. + BrowserSearch._setURLBarPlaceholder(""); + await TestUtils.waitForCondition( + () => gURLBar.placeholder == noEngineString, + "The placeholder should have been reset." + ); + + info("Show search engine removal info bar"); + BrowserSearch.removalOfSearchEngineNotificationBox( + extraEngine.name, + originalEngine.name + ); + const notificationBox = gNotificationBox.getNotificationWithValue( + "search-engine-removal" + ); + Assert.ok(notificationBox, "Search engine removal should be shown."); + + await TestUtils.waitForCondition( + () => gURLBar.placeholder == expectedString, + "The placeholder should include the engine name for built-in engines." + ); + + Assert.equal(gURLBar.placeholder, expectedString); + + notificationBox.close(); +}); + +/** + * Opens the view, clicks a one-off button to enter search mode, and asserts + * that the placeholder is corrrect. + * + * @param {object} expectedSearchMode + * The expected search mode object for the one-off. + * @param {object} expectedPlaceholderL10n + * The expected l10n object for the one-off. + */ +async function doSearchModeTest(expectedSearchMode, expectedPlaceholderL10n) { + // Click the urlbar to open the top-sites view. + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + // Enter search mode. + await UrlbarTestUtils.enterSearchMode(window, expectedSearchMode); + + // Check the placeholder. + Assert.deepEqual( + document.l10n.getAttributes(gURLBar.inputField), + expectedPlaceholderL10n, + "Placeholder has expected l10n" + ); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); +} diff --git a/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js b/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js new file mode 100644 index 0000000000..8d383092fe --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_populateAfterPushState.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* When a user clears the URL bar, and then the page pushes state, we should + * re-fill the URL bar so it doesn't remain empty indefinitely. See bug 1441039. + * For normal loads, this happens automatically because a non-same-document state + * change takes place. + */ +add_task(async function () { + await BrowserTestUtils.withNewTab( + TEST_BASE_URL + "dummy_page.html", + async function (browser) { + gURLBar.value = ""; + + let locationChangePromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + TEST_BASE_URL + "dummy_page2.html" + ); + await SpecialPowers.spawn(browser, [], function () { + content.history.pushState({}, "Page 2", "dummy_page2.html"); + }); + await locationChangePromise; + is( + gURLBar.value, + TEST_BASE_URL + "dummy_page2.html", + "Should have updated the URL bar." + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js new file mode 100644 index 0000000000..941c44441d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Verify that the primary selection is unaffected by opening a new tab. + * + * The steps here follow STR for regression + * https://bugzilla.mozilla.org/show_bug.cgi?id=1457355. + */ + +"use strict"; + +let tabs = []; +let supportsPrimary = Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard +); +const NON_EMPTY_URL = "data:text/html,Hello"; +const TEXT_FOR_PRIMARY = "Text for PRIMARY selection"; + +add_task(async function () { + tabs.push( + await BrowserTestUtils.openNewForegroundTab(gBrowser, NON_EMPTY_URL) + ); + + // Bug 1457355 reproduced only when the url had a non-empty selection. + gURLBar.select(); + Assert.equal(gURLBar.inputField.selectionStart, 0); + Assert.equal( + gURLBar.inputField.selectionEnd, + gURLBar.inputField.value.length + ); + + if (supportsPrimary) { + clipboardHelper.copyStringToClipboard( + TEXT_FOR_PRIMARY, + Services.clipboard.kSelectionClipboard + ); + } + + tabs.push( + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: () => { + // Simulate tab open from user input such as keyboard shortcut or new + // tab button. + let userInput = window.windowUtils.setHandlingUserInput(true); + try { + BrowserOpenTab(); + } finally { + userInput.destruct(); + } + }, + waitForLoad: false, + }) + ); + + if (!supportsPrimary) { + info("Primary selection not supported. Skipping assertion."); + return; + } + + let primaryAsText = SpecialPowers.getClipboardData( + "text/plain", + SpecialPowers.Ci.nsIClipboard.kSelectionClipboard + ); + Assert.equal(primaryAsText, TEXT_FOR_PRIMARY); +}); + +registerCleanupFunction(() => { + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js b/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js new file mode 100644 index 0000000000..eeeda93687 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_privateBrowsingWindowChange.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that when opening a private browsing window and typing in it before + * about:privatebrowsing loads, we don't clear the URL bar. + */ +add_task(async function () { + let urlbarTestValue = "Mary had a little lamb"; + let win = OpenBrowserWindow({ private: true }); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + await BrowserTestUtils.waitForEvent(win, "load"); + let promise = new Promise(resolve => { + let wpl = { + onLocationChange(aWebProgress, aRequest, aLocation) { + if (aLocation && aLocation.spec == "about:privatebrowsing") { + win.gBrowser.removeProgressListener(wpl); + resolve(); + } + }, + }; + win.gBrowser.addProgressListener(wpl); + }); + Assert.notEqual( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:privatebrowsing", + "Check privatebrowsing page has not been loaded yet" + ); + info("Search in urlbar"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: urlbarTestValue, + fireInputEvent: true, + }); + info("waiting for about:privatebrowsing load"); + await promise; + + let urlbar = win.gURLBar; + is( + urlbar.value, + urlbarTestValue, + "URL bar value should be the same once about:privatebrowsing has loaded" + ); + is( + win.gBrowser.selectedBrowser.userTypedValue, + urlbarTestValue, + "User typed value should be the same once about:privatebrowsing has loaded" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_queryContextCache.js b/browser/components/urlbar/tests/browser/browser_queryContextCache.js new file mode 100644 index 0000000000..758043233d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_queryContextCache.js @@ -0,0 +1,482 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the view's QueryContextCache. When the view opens and a context is +// cached for the search, the view should *synchronously* open and update. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.sys.mjs", +}); + +const TEST_URLS = []; +const TEST_URLS_COUNT = 5; +const TOP_SITES_VISIT_COUNT = 5; +const SEARCH_STRING = "example"; + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + // Clear history and bookmarks to make sure the URLs we add below are truly + // the top sites. If any existing history or bookmarks were the top sites, + // which is likely but not guaranteed, one or more "newtab-top-sites-changed" + // notifications will be sent, potentially interfering with the rest of the + // test. Waiting for Places updates to finish and then an extra tick should be + // enough to make sure no more notfications occur. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.promiseAsyncUpdates(); + await TestUtils.waitForTick(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Add some URLs to populate both history and top sites. Each URL needs to + // match `SEARCH_STRING`. + for (let i = 0; i < TEST_URLS_COUNT; i++) { + let url = `https://${i}.example.com/${SEARCH_STRING}`; + TEST_URLS.unshift(url); + // Each URL needs to be added several times to boost its frecency enough to + // qualify as a top site. + for (let j = 0; j < TOP_SITES_VISIT_COUNT; j++) { + await PlacesTestUtils.addVisits(url); + } + } + await updateTopSitesAndAwaitChanged(TEST_URLS_COUNT); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function search() { + await withNewBrowserWindow(async win => { + // Do a search and then close the view. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: SEARCH_STRING, + }); + await UrlbarTestUtils.promisePopupClose(win); + + // Open the view. It should open synchronously and the cached search context + // should be used. + await openViewAndAssertCached({ + win, + searchString: SEARCH_STRING, + cached: true, + }); + }); +}); + +add_task(async function topSites_simple() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the cached + // top-sites context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_nonEmptySearch() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Do a search, close the view, and revert the input. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "test", + }); + await UrlbarTestUtils.promisePopupClose(win); + win.gURLBar.handleRevert(); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_otherEmptySearch() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Enter search mode with an empty search string (by pressing accel+K), + // starting a new search. The view should *not* open synchronously and the + // cached top-sites context should not be used. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("k", { accelKey: true }, win); + Assert.ok(!win.gURLBar.view.isOpen, "View is not open"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: Services.search.defaultEngine.name, + isGeneralPurposeEngine: true, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isPreview: false, + entry: "shortcut", + }); + + // Close the view and revert the input. + await UrlbarTestUtils.promisePopupClose(win); + win.gURLBar.handleRevert(); + await UrlbarTestUtils.assertSearchMode(win, null); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_changed() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Change the top sites by adding visits to a new URL. + let newURL = "https://changed.example.com/"; + for (let j = 0; j < TOP_SITES_VISIT_COUNT; j++) { + await PlacesTestUtils.addVisits(newURL); + } + await updateTopSitesAndAwaitChanged(TEST_URLS_COUNT + 1); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the new URL should be used. + await openViewAndAssertCached({ + win, + cached: true, + urls: [newURL, ...TEST_URLS], + // The new URL is sometimes at the end of the list of top sites instead of + // the start, so ignore the order of the results. + ignoreOrder: true, + }); + + // Remove the new URL. The top sites will update themselves automatically, + // so we only need to wait for newtab-top-sites-changed. + info("Removing new URL and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed"); + await PlacesUtils.history.remove([newURL]); + await changedPromise; + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ win, cached: false }); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the new URL should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_nonTopSitesResults() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Add a provider that returns a result with a suggested index of zero so + // that the first result in the view is not from the top-sites provider. + let suggestedIndexURL = "https://example.com/suggested-index-0"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: lazy.UrlbarProviderTopSites.PRIORITY, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: suggestedIndexURL, + } + ), + { suggestedIndex: 0 } + ), + ], + }); + UrlbarProvidersManager.registerProvider(provider); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. The suggested-index result should not be + // immediately present in the view since it's not in the cached context. + await openViewAndAssertCached({ win, cached: true, keepOpen: true }); + + // After the search has finished, the suggested-index result should be in + // the first row. The search's context should become the newly cached + // top-sites context and it should include the suggested-index result. + Assert.equal( + UrlbarTestUtils.getResultCount(win), + TEST_URLS.length + 1, + "Should be one more result after search finishes" + ); + let details = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.equal( + details.url, + suggestedIndexURL, + "First result after search finishes should be the suggested index result" + ); + + // At this point, the search's context should have become the newly cached + // top-sites context and it should include the suggested-index result. + + await UrlbarTestUtils.promisePopupClose(win); + + // Open the view again. It should open synchronously and the new cached + // top-sites context with the suggested-index URL should be used. + await openViewAndAssertCached({ + win, + cached: true, + urls: [suggestedIndexURL, ...TEST_URLS], + }); + + UrlbarProvidersManager.unregisterProvider(provider); + }); +}); + +add_task(async function topSites_disabled_1() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Disable `browser.urlbar.suggest.topsites`. + UrlbarPrefs.set("suggest.topsites", false); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ + win, + cached: false, + cachedAfterOpen: false, + }); + + // Clear the pref, open the view to show top sites, and close it. + UrlbarPrefs.clear("suggest.topsites"); + await openViewAndAssertCached({ win, cached: false }); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function topSites_disabled_2() { + await withNewBrowserWindow(async win => { + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Disable `browser.newtabpage.activity-stream.feeds.system.topsites`. + Services.prefs.setBoolPref( + "browser.newtabpage.activity-stream.feeds.system.topsites", + false + ); + + // Open the view. It should *not* open synchronously and the cached + // top-sites context should not be used. + await openViewAndAssertCached({ + win, + cached: false, + cachedAfterOpen: false, + }); + + // Clear the pref, open the view to show top sites, and close it. + Services.prefs.clearUserPref( + "browser.newtabpage.activity-stream.feeds.system.topsites" + ); + await openViewAndAssertCached({ win, cached: false }); + + // Open the view. It should open synchronously and the cached top-sites + // context should be used. + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +add_task(async function evict() { + await withNewBrowserWindow(async win => { + let cache = win.gURLBar.view.queryContextCache; + Assert.equal( + typeof cache.size, + "number", + "Sanity check: queryContextCache.size is a number" + ); + + // Open the view to show top sites and then close it. + await openViewAndAssertCached({ win, cached: false }); + + // Do `cache.size` + 1 searches. + for (let i = 0; i < cache.size + 1; i++) { + let searchString = "test" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: searchString, + }); + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + cache.get(searchString), + "Cache includes search string: " + searchString + ); + } + + // The first search string should have been evicted from the cache, but the + // one after that should still be cached. + Assert.ok(!cache.get("test0"), "test0 has been evicted from the cache"); + Assert.ok(cache.get("test1"), "Cache includes test1"); + + // Revert the input and open the view to show the top sites. It should open + // synchronously and the cached top-sites context should be used. + win.gURLBar.handleRevert(); + Assert.equal(win.gURLBar.value, "", "Input is empty after reverting"); + await openViewAndAssertCached({ win, cached: true }); + }); +}); + +/** + * Opens the view and checks that it is or is not synchronously opened and + * populated as specified. + * + * @param {object} options + * Options object. + * @param {window} options.win + * The window to open the view in. + * @param {boolean} options.cached + * Whether a query context is expected to already be cached for the search + * that's performed when the view opens. If true, then the view should + * synchronously open and populate using the cached context. If false, then + * the view should asynchronously open once the first results are fetched. + * @param {boolean} [options.cachedAfterOpen] + * Whether the context is expected to be cached after the view opens and the + * query finishes. + * @param {string} [options.searchString] + * The search string for which the context should or should not be cached. If + * falsey, then the relevant context is assumed to be the top-sites context. + * @param {Array} [options.urls] + * Array of URLs that are expected to be shown in the view. + * @param {boolean} [options.ignoreOrder] + * Whether to treat `urls` as an unordered set instead of an array. When true, + * the order of results is ignored. + * @param {boolean} [options.keepOpen] + * Whether to keep the view open when the function returns. + */ +async function openViewAndAssertCached({ + win, + cached, + cachedAfterOpen = true, + searchString = "", + urls = TEST_URLS, + ignoreOrder = false, + keepOpen = false, +}) { + let cache = win.gURLBar.view.queryContextCache; + let getContext = () => + searchString ? cache.get(searchString) : cache.topSitesContext; + + Assert.equal( + !!getContext(), + cached, + "Context is present or not in cache as expected for search string: " + + JSON.stringify(searchString) + ); + + // Open the view by performing the accel+L command. + await SimpleTest.promiseFocus(win); + win.document.getElementById("Browser:OpenLocation").doCommand(); + + Assert.equal( + win.gURLBar.view.isOpen, + cached, + "View is open or not as expected" + ); + + if (!cached && cachedAfterOpen) { + // Wait for the search to finish and the context to be cached since callers + // generally expect it. + await TestUtils.waitForCondition( + getContext, + "Waiting for context to be cached for search string: " + + JSON.stringify(searchString) + ); + } else if (cached) { + // The view is expected to open synchronously. Check the results. We don't + // do this in the `!cached` case, when the view is expected to open + // asynchronously, because there are plenty of other tests for that. Here we + // want to make sure results are correct before the new search finishes in + // order to avoid any flicker. + let startIndex = 0; + let resultCount = urls.length; + if (searchString) { + // Plus heuristic + startIndex++; + resultCount++; + } + + // In all the checks below, check the rows container directly instead of + // relying on `UrlbarTestUtils` functions that wait for the search to + // finish. Here we're specifically checking cached results that should be + // used before the search finishes. + let rows = UrlbarTestUtils.getResultsContainer(win).children; + Assert.equal(rows.length, resultCount, "View has expected row count"); + + // Check the search heuristic row. + if (searchString) { + let result = rows[0].result; + Assert.ok(result.heuristic, "First row should be a heuristic"); + Assert.equal( + result.payload.query, + searchString, + "First row's query should be the search string" + ); + } + + // Check the URL rows. + let actualURLs = []; + let urlRows = Array.from(rows).slice(startIndex); + for (let row of urlRows) { + actualURLs.push(row.result.payload.url); + } + if (ignoreOrder) { + urls.sort(); + actualURLs.sort(); + } + Assert.deepEqual(actualURLs, urls, "View should contain the expected URLs"); + } + + // Now wait for the search to finish before returning. We await + // `lastQueryContextPromise` instead of the promise returned from + // `UrlbarTestUtils.promiseSearchComplete()` because the latter assumes the + // view will open, which isn't the case for every task here. + await win.gURLBar.lastQueryContextPromise; + if (!keepOpen) { + await UrlbarTestUtils.promisePopupClose(win); + } +} + +/** + * Updates the top sites and waits for the "newtab-top-sites-changed" + * notification. Note that this notification is not sent if the sites don't + * actually change. In that case, use only `updateTopSites()` instead. + * + * @param {number} expectedCount + * The new expected number of top sites. + */ +async function updateTopSitesAndAwaitChanged(expectedCount) { + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length == expectedCount); + await changedPromise; +} + +async function withNewBrowserWindow(callback) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_quickactions.js b/browser/components/urlbar/tests/browser/browser_quickactions.js new file mode 100644 index 0000000000..5945910067 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions.js @@ -0,0 +1,783 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests QuickActions. + */ + +"use strict"; + +requestLongerTimeout(3); + +ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + UpdateService: "resource://gre/modules/UpdateService.sys.mjs", + + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +const DUMMY_PAGE = + "http://example.com/browser/browser/base/content/test/general/dummy_page.html"; + +let testActionCalled = 0; + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + label: "quickactions-downloads2", + onPick: () => testActionCalled++, + }); + + registerCleanupFunction(() => { + UrlbarProviderQuickActions.removeAction("testaction"); + }); +}); + +add_task(async function basic() { + info("The action isnt shown when not matched"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "nomatch", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We did no match anything" + ); + + info("A prefix of the command matches"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "testact", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We matched the action" + ); + + info("The callback of the action is fired when selected"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + Assert.equal(testActionCalled, 1, "Test actionwas called"); +}); + +add_task(async function test_label_command() { + info("A prefix of the label matches"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "View Dow", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We matched the action" + ); + + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.providerName, "quickactions"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +}); + +add_task(async function enter_search_mode_button() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let oneOffButton = await TestUtils.waitForCondition(() => + window.document.getElementById("urlbar-engine-one-off-item-actions") + ); + Assert.ok(oneOffButton, "One off button is available when preffed on"); + + EventUtils.synthesizeMouseAtCenter(oneOffButton, {}, window); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + entry: "oneoff", + }); + + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.ok(true, "Actions are shown when we enter actions search mode."); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); +}); + +add_task(async function enter_search_mode_oneoff_by_key() { + // Select actions oneoff button by keyboard. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + const oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + for (;;) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + if ( + oneOffButtons.selectedButton.source === UrlbarUtils.RESULT_SOURCE.ACTIONS + ) { + break; + } + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + entry: "oneoff", + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); +}); + +add_task(async function enter_search_mode_key() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "> ", + }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + entry: "typed", + }); + Assert.equal( + await hasQuickActions(window), + true, + "Actions are shown in search mode" + ); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); +}); + +add_task(async function test_disabled() { + UrlbarProviderQuickActions.addAction("disabledaction", { + commands: ["disabledaction"], + isActive: () => false, + label: "quickactions-restart", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "disabled", + }); + + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is hidden" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProviderQuickActions.removeAction("disabledaction"); +}); + +/** + * The first part of this test confirms that when the screenshots component is enabled + * the screenshot quick action button will be enabled on about: pages. + * The second part confirms that when the screenshots extension is enabled the + * screenshot quick action button will be disbaled on about: pages. + */ +add_task(async function test_screenshot_enabled_or_disabled() { + let onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "about:blank" + ); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank"); + await onLoaded; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "The action is displayed" + ); + let screenshotButton = window.document.querySelector( + ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-row" + ); + Assert.ok( + !screenshotButton.hasAttribute("disabled"), + "Screenshot button is enabled on about pages" + ); + + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); + + await SpecialPowers.pushPrefEnv({ + set: [["screenshots.browser.component.enabled", false]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + }); + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is hidden" + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function match_in_phrase() { + UrlbarProviderQuickActions.addAction("newtestaction", { + commands: ["matchingstring"], + label: "quickactions-downloads2", + }); + + info("The action is matched when at end of input"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "Test we match at end of matchingstring", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We matched the action" + ); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); + UrlbarProviderQuickActions.removeAction("newtestaction"); +}); + +async function isScreenshotInitialized() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let screenshotsChild = content.windowGlobalChild.getActor( + "ScreenshotsComponent" + ); + return screenshotsChild?._overlay?._initialized; + }); +} + +add_task(async function test_screenshot() { + await SpecialPowers.pushPrefEnv({ + set: [["screenshots.browser.component.enabled", true]], + }); + + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, DUMMY_PAGE); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + DUMMY_PAGE + ); + + info("The action is matched when at end of input"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We matched the action" + ); + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.providerName, "quickactions"); + + info("Trigger the screenshot mode"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await TestUtils.waitForCondition( + isScreenshotInitialized, + "Screenshot component is active", + 200, + 100 + ); + + info("Press Escape to exit screenshot mode"); + EventUtils.synthesizeKey("KEY_Escape", {}, window); + await TestUtils.waitForCondition( + async () => !(await isScreenshotInitialized()), + "Screenshot component has been dismissed" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); +}); + +add_task(async function test_other_search_mode() { + let defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + }); + defaultEngine.alias = "testalias"; + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: defaultEngine.alias + " ", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "The results should be empty as no actions are displayed in other search modes" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: defaultEngine.name, + entry: "typed", + }); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); + +add_task(async function test_no_quickactions_suggestions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + }); + Assert.ok( + !window.document.querySelector( + ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-row" + ), + "Screenshot button is not suggested" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "> screenshot", + }); + Assert.ok( + window.document.querySelector( + ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-row" + ), + "Screenshot button is suggested" + ); + + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_quickactions_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", false], + ["browser.urlbar.suggest.quickactions", true], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "screenshot", + }); + + Assert.ok( + !window.document.querySelector( + ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-row" + ), + "Screenshot button is not suggested" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "> screenshot", + }); + Assert.ok( + !window.document.querySelector( + ".urlbarView-row[dynamicType=quickactions] .urlbarView-quickaction-row" + ), + "Screenshot button is not suggested" + ); + + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_Escape"); + + await SpecialPowers.popPrefEnv(); +}); + +let COMMANDS_TESTS = [ + { + cmd: "add-ons", + uri: "about:addons", + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + uri: "about:addons", + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + uri: "about:addons", + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + uri: "about:addons", + testFun: async () => isSelected("button[name=theme]"), + }, + { + cmd: "add-ons", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://example.com/" + ); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://example.com/" + ); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://example.com/" + ); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + setup: async () => { + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "http://example.com/" + ); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com/" + ); + await onLoad; + }, + uri: "about:addons", + isNewTab: true, + testFun: async () => isSelected("button[name=theme]"), + }, +]; + +let isSelected = async selector => + SpecialPowers.spawn(gBrowser.selectedBrowser, [selector], arg => { + return ContentTaskUtils.waitForCondition(() => + content.document.querySelector(arg)?.hasAttribute("selected") + ); + }); + +add_task(async function test_pages() { + for (const { cmd, uri, setup, isNewTab, testFun } of COMMANDS_TESTS) { + info(`Testing ${cmd} command is triggered`); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + if (setup) { + info("Setup"); + await setup(); + } + + let onLoad = isNewTab + ? BrowserTestUtils.waitForNewTab(gBrowser, uri, true) + : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, uri); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + + const newTab = await onLoad; + + Assert.ok( + await testFun(), + `The command "${cmd}" passed completed its test` + ); + + if (isNewTab) { + await BrowserTestUtils.removeTab(newTab); + } + await BrowserTestUtils.removeTab(tab); + } +}); + +const assertActionButtonStatus = async (name, expectedEnabled, description) => { + await BrowserTestUtils.waitForCondition(() => + window.document.querySelector(`[data-key=${name}]`) + ); + const target = window.document.querySelector(`[data-key=${name}]`); + Assert.equal(!target.hasAttribute("disabled"), expectedEnabled, description); +}; + +add_task(async function test_viewsource() { + info("Check the button status of when the page is not web content"); + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:home", + waitForLoad: true, + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "viewsource", + }); + await assertActionButtonStatus( + "viewsource", + true, + "Should be enabled even if the page is not web content" + ); + + info("Check the button status of when the page is web content"); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "http://example.com" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "viewsource", + }); + await assertActionButtonStatus( + "viewsource", + true, + "Should be enabled on web content as well" + ); + + info("Do view source action"); + const onLoad = BrowserTestUtils.waitForNewTab( + gBrowser, + "view-source:http://example.com/" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + const viewSourceTab = await onLoad; + + info("Do view source action on the view-source page"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "viewsource", + }); + + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is hidden" + ); + + // Clean up. + BrowserTestUtils.removeTab(viewSourceTab); + BrowserTestUtils.removeTab(tab); +}); + +async function doAlertDialogTest({ input, dialogContentURI }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + const onDialog = BrowserTestUtils.promiseAlertDialog(null, null, { + isSubDialog: true, + callback: win => { + Assert.equal(win.location.href, dialogContentURI, "The dialog is opened"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + }, + }); + + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + + await onDialog; +} + +add_task(async function test_refresh() { + await doAlertDialogTest({ + input: "refresh", + dialogContentURI: "chrome://global/content/resetProfile.xhtml", + }); +}); + +add_task(async function test_clear() { + await doAlertDialogTest({ + input: "clear", + dialogContentURI: "chrome://browser/content/sanitize.xhtml", + }); +}); + +async function doUpdateActionTest(isActiveExpected, description) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "update", + }); + + if (isActiveExpected) { + await assertActionButtonStatus("update", isActiveExpected, description); + } else { + Assert.equal(await hasQuickActions(window), false, description); + } +} + +add_task(async function test_update() { + if (!AppConstants.MOZ_UPDATER) { + await doUpdateActionTest( + false, + "Should be disabled since not AppConstants.MOZ_UPDATER" + ); + return; + } + + const sandbox = sinon.createSandbox(); + try { + sandbox + .stub(UpdateService.prototype, "currentState") + .get(() => Ci.nsIApplicationUpdateService.STATE_IDLE); + await doUpdateActionTest( + false, + "Should be disabled since current update state is not pending" + ); + sandbox + .stub(UpdateService.prototype, "currentState") + .get(() => Ci.nsIApplicationUpdateService.STATE_PENDING); + await doUpdateActionTest( + true, + "Should be enabled since current update state is pending" + ); + } finally { + sandbox.restore(); + } +}); + +async function hasQuickActions(win) { + for (let i = 0, count = UrlbarTestUtils.getResultCount(win); i < count; i++) { + const { result } = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + if (result.providerName === "quickactions") { + return true; + } + } + return false; +} + +add_task(async function test_show_in_zero_prefix() { + for (const minimumSearchString of [0, 3]) { + info( + `Test when quickactions.minimumSearchString pref is ${minimumSearchString}` + ); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.quickactions.minimumSearchString", + minimumSearchString, + ], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + Assert.equal( + await hasQuickActions(window), + !minimumSearchString, + "Result for quick actions is as expected" + ); + await SpecialPowers.popPrefEnv(); + } +}); + +add_task(async function test_whitespace() { + info("Test with quickactions.showInZeroPrefix pref is false"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.showInZeroPrefix", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown" + ); + await SpecialPowers.popPrefEnv(); + + info("Test with quickactions.showInZeroPrefix pref is true"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.showInZeroPrefix", true]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + const countForEmpty = window.document.querySelectorAll( + ".urlbarView-quickaction-row" + ).length; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + const countForWhitespace = window.document.querySelectorAll( + ".urlbarView-quickaction-row" + ).length; + Assert.equal( + countForEmpty, + countForWhitespace, + "Count of quick actions of empty and whitespace are same" + ); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js new file mode 100644 index 0000000000..d113a4c3a8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_devtools.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests QuickActions related to DevTools. + */ + +"use strict"; + +requestLongerTimeout(2); + +ChromeUtils.defineESModuleGetters(this, { + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); +}); + +const assertActionButtonStatus = async (name, expectedEnabled, description) => { + await BrowserTestUtils.waitForCondition(() => + window.document.querySelector(`[data-key=${name}]`) + ); + const target = window.document.querySelector(`[data-key=${name}]`); + Assert.equal(!target.hasAttribute("disabled"), expectedEnabled, description); +}; + +async function hasQuickActions(win) { + for (let i = 0, count = UrlbarTestUtils.getResultCount(win); i < count; i++) { + const { result } = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + if (result.providerName === "quickactions") { + return true; + } + } + return false; +} + +add_task(async function test_inspector() { + const testData = [ + { + description: "Test for 'about:' page", + page: "about:home", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for another 'about:' page", + page: "about:about", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for another devtools-toolbox page", + page: "about:devtools-toolbox", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for web content", + page: "https://example.com", + isDevToolsUser: true, + actionVisible: true, + actionEnabled: true, + }, + { + description: "Test for disabled DevTools", + page: "https://example.com", + prefs: [["devtools.policy.disabled", true]], + isDevToolsUser: true, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for not DevTools user", + page: "https://example.com", + isDevToolsUser: false, + actionVisible: true, + actionEnabled: false, + }, + { + description: "Test for fully disabled", + page: "https://example.com", + prefs: [["devtools.policy.disabled", true]], + isDevToolsUser: false, + actionVisible: false, + }, + ]; + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + for (const { + description, + page, + prefs = [], + isDevToolsUser, + actionEnabled, + actionVisible, + } of testData) { + info(description); + + info("Set preferences"); + await SpecialPowers.pushPrefEnv({ + set: [...prefs, ["devtools.selfxss.count", isDevToolsUser ? 5 : 0]], + }); + + info("Check the button status"); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, page); + await onLoad; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "inspector", + }); + + if (actionVisible && actionEnabled) { + await assertActionButtonStatus( + "inspect", + true, + "The status of action button is correct" + ); + } else { + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown since the inspector tool is disabled" + ); + } + + await SpecialPowers.popPrefEnv(); + + if (!actionVisible || !actionEnabled) { + continue; + } + + info("Do inspect action"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.waitForCondition( + () => DevToolsShim.hasToolboxForTab(gBrowser.selectedTab), + "Wait for opening inspector for current selected tab" + ); + const toolbox = await DevToolsShim.getToolboxForTab(gBrowser.selectedTab); + await BrowserTestUtils.waitForCondition( + () => toolbox.getPanel("inspector"), + "Wait until the inspector is ready" + ); + + info("Do inspect action again in the same page during opening inspector"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "inspector", + }); + Assert.equal( + await hasQuickActions(window), + false, + "Result for quick actions is not shown since the inspector is already opening" + ); + + info( + "Select another tool to check whether the inspector will be selected in next test even if the previous tool is not inspector" + ); + await toolbox.selectTool("options"); + await toolbox.destroy(); + } + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js new file mode 100644 index 0000000000..f969528806 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_quickactions_tab_refocus.js @@ -0,0 +1,194 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests for QuickActions that re-focus tab.. + */ + +"use strict"; + +requestLongerTimeout(3); + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); +}); + +let isSelected = async selector => + SpecialPowers.spawn(gBrowser.selectedBrowser, [selector], arg => { + return ContentTaskUtils.waitForCondition(() => + content.document.querySelector(arg)?.hasAttribute("selected") + ); + }); + +add_task(async function test_about_pages() { + const testData = [ + { + firstInput: "downloads", + uri: "about:downloads", + }, + { + firstInput: "logins", + uri: "about:logins", + }, + { + firstInput: "settings", + uri: "about:preferences", + }, + { + firstInput: "add-ons", + uri: "about:addons", + component: "button[name=discover]", + }, + { + firstInput: "extensions", + uri: "about:addons", + component: "button[name=extension]", + }, + { + firstInput: "plugins", + uri: "about:addons", + component: "button[name=plugin]", + }, + { + firstInput: "themes", + uri: "about:addons", + component: "button[name=theme]", + }, + { + firstLoad: "about:preferences#home", + secondInput: "settings", + uri: "about:preferences#home", + }, + ]; + + for (const { + firstInput, + firstLoad, + secondInput, + uri, + component, + } of testData) { + info("Setup initial state"); + let firstTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri + ); + if (firstLoad) { + info("Load initial URI"); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, uri); + } else { + info("Open about page by quick action"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstInput, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + } + await onLoad; + + if (component) { + info("Check whether the component is in the page"); + Assert.ok(await isSelected(component), "There is expected component"); + } + + info("Do the second quick action in second tab"); + let secondTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: secondInput || firstInput, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + Assert.equal( + gBrowser.selectedTab, + firstTab, + "Switched to the tab that is opening the about page" + ); + Assert.equal( + gBrowser.selectedBrowser.currentURI.spec, + uri, + "URI is not changed" + ); + Assert.equal(gBrowser.tabs.length, 3, "Not opened a new tab"); + + if (component) { + info("Check whether the component is still in the page"); + Assert.ok(await isSelected(component), "There is expected component"); + } + + BrowserTestUtils.removeTab(secondTab); + BrowserTestUtils.removeTab(firstTab); + } +}); + +add_task(async function test_about_addons_pages() { + let testData = [ + { + cmd: "add-ons", + testFun: async () => isSelected("button[name=discover]"), + }, + { + cmd: "plugins", + testFun: async () => isSelected("button[name=plugin]"), + }, + { + cmd: "extensions", + testFun: async () => isSelected("button[name=extension]"), + }, + { + cmd: "themes", + testFun: async () => isSelected("button[name=theme]"), + }, + ]; + + info("Pick all actions related about:addons"); + let originalTab = gBrowser.selectedTab; + for (const { cmd, testFun } of testData) { + await BrowserTestUtils.openNewForegroundTab(gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + Assert.ok(await testFun(), "The page content is correct"); + } + Assert.equal( + gBrowser.tabs.length, + testData.length + 1, + "Tab length is correct" + ); + + info("Pick all again"); + for (const { cmd, testFun } of testData) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: cmd, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("KEY_Enter", {}, window); + await BrowserTestUtils.waitForCondition(() => testFun()); + Assert.ok(true, "The tab correspondent action is selected"); + } + Assert.equal( + gBrowser.tabs.length, + testData.length + 1, + "Tab length is not changed" + ); + + for (const tab of gBrowser.tabs) { + if (tab !== originalTab) { + BrowserTestUtils.removeTab(tab); + } + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_raceWithTabs.js b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js new file mode 100644 index 0000000000..17560ea101 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js @@ -0,0 +1,86 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +async function addBookmark(bookmark) { + info("Creating bookmark and keyword"); + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmark.url, + title: bookmark.title, + }); + if (bookmark.keyword) { + await PlacesUtils.keywords.insert({ + keyword: bookmark.keyword, + url: bookmark.url, + }); + } + + registerCleanupFunction(async function () { + if (bookmark.keyword) { + await PlacesUtils.keywords.remove(bookmark.keyword); + } + await PlacesUtils.bookmarks.remove(bm); + }); +} + +/** + * Check that if the user hits enter and ctrl-t at the same time, we open the + * URL in the right tab. + */ +add_task(async function hitEnterLoadInRightTab() { + await addBookmark({ + title: "Test for keyword bookmark and URL", + url: TEST_URL, + keyword: "urlbarkeyword", + }); + + info("Opening a tab"); + let oldTabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + BrowserOpenTab(); + let oldTab = (await oldTabOpenPromise).target; + let oldTabLoadedPromise = BrowserTestUtils.browserLoaded( + oldTab.linkedBrowser, + false, + TEST_URL + ).then(() => info("Old tab loaded")); + + info("Filling URL bar, sending <return> and opening a tab"); + let tabOpenPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + gURLBar.value = "urlbarkeyword"; + gURLBar.focus(); + gURLBar.select(); + EventUtils.sendKey("return"); + + info("Immediately open a second tab"); + BrowserOpenTab(); + let newTab = (await tabOpenPromise).target; + + info("Created new tab; waiting for tabs to load"); + let newTabLoadedPromise = BrowserTestUtils.browserLoaded( + newTab.linkedBrowser, + false, + "about:newtab" + ).then(() => info("New tab loaded")); + // If one of the tabs loads the wrong page, this will timeout, and that + // indicates we regressed this bug fix. + await Promise.all([newTabLoadedPromise, oldTabLoadedPromise]); + // These are not particularly useful, but the test must contain some checks. + is( + newTab.linkedBrowser.currentURI.spec, + "about:newtab", + "New tab loaded about:newtab" + ); + is(oldTab.linkedBrowser.currentURI.spec, TEST_URL, "Old tab loaded URL"); + + info("Closing tabs"); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(oldTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_redirect_error.js b/browser/components/urlbar/tests/browser/browser_redirect_error.js new file mode 100644 index 0000000000..2fc6155cd5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_redirect_error.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const REDIRECT_FROM = `${TEST_BASE_URL}redirect_error.sjs`; + +const REDIRECT_TO = "https://www.bank1.com/"; // Bad-cert host. + +function isRedirectedURISpec(aURISpec) { + return isRedirectedURI(Services.io.newURI(aURISpec)); +} + +function isRedirectedURI(aURI) { + // Compare only their before-hash portion. + return Services.io.newURI(REDIRECT_TO).equalsExceptRef(aURI); +} + +/* + Test. + +1. Load redirect_bug623155.sjs#BG in a background tab. + +2. The redirected URI is <https://www.bank1.com/#BG>, which displayes a cert + error page. + +3. Switch the tab to foreground. + +4. Check the URLbar's value, expecting <https://www.bank1.com/#BG> + +5. Load redirect_bug623155.sjs#FG in the foreground tab. + +6. The redirected URI is <https://www.bank1.com/#FG>. And this is also + a cert-error page. + +7. Check the URLbar's value, expecting <https://www.bank1.com/#FG> + +8. End. + + */ + +var gNewTab; + +function test() { + waitForExplicitFinish(); + + // Load a URI in the background. + gNewTab = BrowserTestUtils.addTab(gBrowser, REDIRECT_FROM + "#BG"); + gBrowser + .getBrowserForTab(gNewTab) + .webProgress.addProgressListener( + gWebProgressListener, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); +} + +var gWebProgressListener = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + // --------------------------------------------------------------------------- + // NOTIFY_LOCATION mode should work fine without these methods. + // + // onStateChange: function() {}, + // onStatusChange: function() {}, + // onProgressChange: function() {}, + // onSecurityChange: function() {}, + // ---------------------------------------------------------------------------- + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) { + if (!aRequest) { + // This is bug 673752, or maybe initial "about:blank". + return; + } + + ok(gNewTab, "There is a new tab."); + ok( + isRedirectedURI(aLocation), + "onLocationChange catches only redirected URI." + ); + + if (aLocation.ref == "BG") { + // This is background tab's request. + isnot(gNewTab, gBrowser.selectedTab, "This is a background tab."); + } else if (aLocation.ref == "FG") { + // This is foreground tab's request. + is(gNewTab, gBrowser.selectedTab, "This is a foreground tab."); + } else { + // We shonuld not reach here. + ok(false, "This URI hash is not expected:" + aLocation.ref); + } + + let isSelectedTab = gNewTab.selected; + setTimeout(delayed, 0, isSelectedTab); + }, +}; + +function delayed(aIsSelectedTab) { + // Switch tab and confirm URL bar. + if (!aIsSelectedTab) { + gBrowser.selectedTab = gNewTab; + } + + let currentURI = gBrowser.selectedBrowser.currentURI.spec; + ok( + isRedirectedURISpec(currentURI), + "The content area is redirected. aIsSelectedTab:" + aIsSelectedTab + ); + is( + gURLBar.value, + currentURI, + "The URL bar shows the content URI. aIsSelectedTab:" + aIsSelectedTab + ); + + if (!aIsSelectedTab) { + // If this was a background request, go on a foreground request. + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + REDIRECT_FROM + "#FG" + ); + } else { + // Othrewise, nothing to do remains. + finish(); + } +} + +/* Cleanup */ +registerCleanupFunction(function () { + if (gNewTab) { + gBrowser + .getBrowserForTab(gNewTab) + .webProgress.removeProgressListener(gWebProgressListener); + + gBrowser.removeTab(gNewTab); + } + gNewTab = null; +}); diff --git a/browser/components/urlbar/tests/browser/browser_remoteness_switch.js b/browser/components/urlbar/tests/browser/browser_remoteness_switch.js new file mode 100644 index 0000000000..364eeff1b2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remoteness_switch.js @@ -0,0 +1,56 @@ +"use strict"; + +/** + * Verify that when loading and going back/forward through history between URLs + * loaded in the content process, and URLs loaded in the parent process, we + * don't set the URL for the tab to about:blank inbetween the loads. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + let url = "http://www.example.com/foo.html"; + await BrowserTestUtils.withNewTab( + { gBrowser, url }, + async function (browser) { + let wpl = { + onLocationChange(unused, unused2, location) { + if (location.schemeIs("about")) { + is( + location.spec, + "about:config", + "Only about: location change should be for about:preferences" + ); + } else { + is( + location.spec, + url, + "Only non-about: location change should be for the http URL we're dealing with." + ); + } + }, + }; + gBrowser.addProgressListener(wpl); + + let didLoad = BrowserTestUtils.browserLoaded( + browser, + null, + function (loadedURL) { + return loadedURL == "about:config"; + } + ); + BrowserTestUtils.loadURIString(browser, "about:config"); + await didLoad; + + gBrowser.goBack(); + await BrowserTestUtils.browserLoaded(browser, null, function (loadedURL) { + return url == loadedURL; + }); + gBrowser.goForward(); + await BrowserTestUtils.browserLoaded(browser, null, function (loadedURL) { + return loadedURL == "about:config"; + }); + gBrowser.removeProgressListener(wpl); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_remotetab.js b/browser/components/urlbar/tests/browser/browser_remotetab.js new file mode 100644 index 0000000000..1fde855dbd --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remotetab.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests checks that the remote tab result is displayed and can be + * selected. + */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: TEST_URL, + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], +}; + +add_setup(async function () { + sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", false], + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + registerCleanupFunction(async () => { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + }); +}); + +add_task(async function test_remotetab_opens() { + await BrowserTestUtils.withNewTab( + { url: "about:robots", gBrowser }, + async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "Test Remote", + }); + + // There should be two items in the pop-up, the first is the default search + // suggestion, the second is the remote tab. + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "Should be the remote tab entry" + ); + + // The URL is going to open in the current tab as it is currently about:blank + let promiseTabLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await promiseTabLoaded; + + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + TEST_URL, + "correct URL loaded" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js b/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js new file mode 100644 index 0000000000..4dfbc5c01b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_removeUnsafeProtocolsFromURLBarPaste.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensures that pasting unsafe protocols in the urlbar have the protocol + * correctly stripped. + */ + +var pairs = [ + ["javascript:", ""], + ["javascript:1+1", "1+1"], + ["javascript:document.domain", "document.domain"], + [ + " \u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009javascript:document.domain", + "document.domain", + ], + ["java\nscript:foo", "foo"], + ["java\tscript:foo", "foo"], + ["http://\nexample.com", "http://example.com"], + ["http://\nexample.com\n", "http://example.com"], + ["data:text/html,<body>hi</body>", "data:text/html,<body>hi</body>"], + ["javaScript:foopy", "foopy"], + ["javaScript:javaScript:alert('hi')", "alert('hi')"], + // Nested things get confusing because some things don't parse as URIs: + ["javascript:javascript:alert('hi!')", "alert('hi!')"], + [ + "data:data:text/html,<body>hi</body>", + "data:data:text/html,<body>hi</body>", + ], + ["javascript:data:javascript:alert('hi!')", "data:javascript:alert('hi!')"], + [ + "javascript:data:text/html,javascript:alert('hi!')", + "data:text/html,javascript:alert('hi!')", + ], + [ + "data:data:text/html,javascript:alert('hi!')", + "data:data:text/html,javascript:alert('hi!')", + ], +]; + +let supportsNullBytes = AppConstants.platform == "macosx"; +// Note that \u000d (\r) is missing here; we test it separately because it +// makes the test sad on Windows. +let nonsense = + "\u000a\u000b\u000c\u000e\u000f\u0010\u0011\u0012\u0013\u0014javascript:foo"; +if (supportsNullBytes) { + nonsense = "\u0000" + nonsense; +} +pairs.push([nonsense, "foo"]); + +let supportsReturnWithoutNewline = + AppConstants.platform != "win" && AppConstants.platform != "linux"; +if (supportsReturnWithoutNewline) { + pairs.push(["java\rscript:foo", "foo"]); +} + +async function paste(input) { + try { + await SimpleTest.promiseClipboardChange( + aData => { + // This test checks how "\r" is treated. Therefore, we cannot specify + // string here and instead, we need to compare strictly with this + // function. + return aData === input; + }, + () => { + clipboardHelper.copyString(input); + } + ); + } catch (ex) { + Assert.ok(false, "Failed to copy string '" + input + "' to clipboard"); + } + + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} + +add_task(async function test_stripUnsafeProtocolPaste() { + for (let [inputValue, expectedURL] of pairs) { + gURLBar.value = ""; + gURLBar.focus(); + await paste(inputValue); + + Assert.equal( + gURLBar.value, + expectedURL, + `entering ${inputValue} strips relevant bits.` + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_remove_match.js b/browser/components/urlbar/tests/browser/browser_remove_match.js new file mode 100644 index 0000000000..94a1c874bf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remove_match.js @@ -0,0 +1,297 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", +}); + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); +}); + +add_task(async function test_remove_history() { + const TEST_URL = "http://remove.me/from_urlbar/"; + await PlacesTestUtils.addVisits(TEST_URL); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + let promiseVisitRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URL + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + + let expectedResultCount = UrlbarTestUtils.getResultCount(window) - 1; + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + const removeEvents = await promiseVisitRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == expectedResultCount, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < expectedResultCount; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.url, + TEST_URL, + "Should not find the test URL in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function test_remove_form_history() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let formHistoryValue = "foobar"; + await UrlbarTestUtils.formHistory.add([formHistoryValue]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [formHistoryValue], + "Should find form history after adding it" + ); + + let promiseRemoved = UrlbarTestUtils.formHistory.promiseChanged("remove"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let index = 1; + let count = UrlbarTestUtils.getResultCount(window); + for (; index < count; index++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + break; + } + } + Assert.ok(index < count, "Result found"); + + EventUtils.synthesizeKey("KEY_Tab", { repeat: index }); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), index); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await promiseRemoved; + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == count - 1, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != UrlbarUtils.RESULT_SOURCE.HISTORY, + "Should not find the form history result in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [], + "Should not find form history after removing it" + ); + + await SpecialPowers.popPrefEnv(); +}); + +// We shouldn't be able to remove a bookmark item. +add_task(async function test_remove_bookmark_doesnt() { + const TEST_URL = "http://dont.remove.me/from_urlbar/"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test", + url: TEST_URL, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + // We don't have an easy way of determining if the event was process or not, + // so let any event queues clear before testing. + await new Promise(resolve => setTimeout(resolve, 0)); + await PlacesTestUtils.promiseAsyncUpdates(); + + Assert.ok( + await PlacesUtils.bookmarks.fetch({ url: TEST_URL }), + "Should still have the URL bookmarked." + ); +}); + +add_task(async function test_searchMode_removeRestyledHistory() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let query = "ciao"; + let url = `https://example.com/?q=${query}bar`; + await PlacesTestUtils.addVisits(url); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await TestUtils.waitForCondition( + async () => !(await PlacesTestUtils.isPageInDB(url)), + "Wait for url to be removed from history" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Urlbar result should be removed" + ); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function blockButton() { + if (UrlbarPrefs.get("resultMenu")) { + // This case is covered by browser_result_menu.js. + return; + } + + let url = "https://example.com/has-block-button"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url, + isBlockable: true, + blockL10n: { id: "firefox-suggest-urlbar-block" }, + } + ), + ], + }); + + // Implement the provider's `onEngagement()` so it removes the result. + let onEngagementCallCount = 0; + provider.onEngagement = (isPrivate, state, queryContext, details) => { + onEngagementCallCount++; + queryContext.view.controller.removeResult(details.result); + }; + + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.result.payload.url, + url, + "The result should be in the first row" + ); + + let button = row.querySelector(".urlbarView-button-block"); + Assert.ok(button, "The row should have a block button"); + + info("Tabbing down to block button"); + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + button, + "The block button should be selected after tabbing down" + ); + + info("Pressing Enter on block button"); + EventUtils.synthesizeKey("KEY_Enter"); + + Assert.equal( + onEngagementCallCount, + 1, + "onEngagement() should have been called once" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "There should be no results after blocking" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js b/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js new file mode 100644 index 0000000000..096d8e2134 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// When the input is empty and the view is opened, keying down through the +// results and then out of the results should restore the empty input. + +"use strict"; + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + // Update Top Sites to make sure the last Top Site is a URL. Otherwise, it + // would be a search shortcut and thus would not fill the Urlbar when + // selected. + await updateTopSites(sites => { + return ( + sites && + sites[sites.length - 1] && + sites[sites.length - 1].url == "http://example.com/" + ); + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Nothing selected" + ); + + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.greater(resultCount, 0, "At least one result"); + + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + resultCount - 1, + "Last result selected" + ); + Assert.notEqual(gURLBar.value, "", "Input should not be empty"); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Nothing selected" + ); + Assert.equal(gURLBar.value, "", "Input should be empty"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_resultSpan.js b/browser/components/urlbar/tests/browser/browser_resultSpan.js new file mode 100644 index 0000000000..9b17fb71f5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_resultSpan.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that displaying results with resultSpan > 1 limits other results in +// the view. + +const TEST_RESULTS = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/1" } + ), + makeTipResult(), +]; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); +const TIP_SPAN = UrlbarUtils.getSpanForResult({ + type: UrlbarUtils.RESULT_TYPE.TIP, +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); +}); + +// A restricting provider with one tip result and many history results. +add_task(async function oneTip() { + let results = Array.from(TEST_RESULTS); + for (let i = TEST_RESULTS.length; i < MAX_RESULTS; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results).slice( + 0, + MAX_RESULTS - TIP_SPAN + 1 + ); + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A restricting provider with three tip results and many history results. +add_task(async function threeTips() { + let results = Array.from(TEST_RESULTS); + for (let i = 1; i < 3; i++) { + results.push(makeTipResult()); + } + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results).slice( + 0, + MAX_RESULTS - 3 * (TIP_SPAN - 1) + ); + + let provider = new UrlbarTestUtils.TestProvider({ results, priority: 1 }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A non-restricting provider with one tip result and many history results. +add_task(async function oneTip_nonRestricting() { + let results = Array.from(TEST_RESULTS); + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results); + + // UrlbarProviderHeuristicFallback's heuristic search result + expectedResults.unshift({ + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + payload: { + engine: Services.search.defaultEngine.name, + query: "test", + }, + }); + + expectedResults = expectedResults.slice(0, MAX_RESULTS - TIP_SPAN + 1); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +// A non-restricting provider with three tip results and many history results. +add_task(async function threeTips_nonRestricting() { + let results = Array.from(TEST_RESULTS); + for (let i = 1; i < 3; i++) { + results.push(makeTipResult()); + } + for (let i = 2; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + let expectedResults = Array.from(results); + + // UrlbarProviderHeuristicFallback's heuristic search result + expectedResults.unshift({ + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + payload: { + engine: Services.search.defaultEngine.name, + query: "test", + }, + }); + + expectedResults = expectedResults.slice(0, MAX_RESULTS - 3 * (TIP_SPAN - 1)); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +add_task(async function customValue() { + let results = []; + for (let i = 0; i < 15; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: `http://mozilla.org/${i}` } + ) + ); + } + + results[1].resultSpan = 5; + + let expectedResults = Array.from(results); + expectedResults = expectedResults.slice(0, 6); + + let provider = new UrlbarTestUtils.TestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + checkResults(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + gURLBar.view.close(); +}); + +function checkResults(actual, expected) { + Assert.equal(actual.length, expected.length, "Number of results"); + for (let i = 0; i < expected.length; i++) { + info(`Checking results at index ${i}`); + let actualResult = collectExpectedProperties(actual[i], expected[i]); + Assert.deepEqual(actualResult, expected[i], "Actual vs. expected result"); + } +} + +function collectExpectedProperties(actualObj, expectedObj) { + let newActualObj = {}; + for (let name in expectedObj) { + if (typeof expectedObj[name] == "object") { + newActualObj[name] = collectExpectedProperties( + actualObj[name], + expectedObj[name] + ); + } else { + newActualObj[name] = expectedObj[name]; + } + } + return newActualObj; +} + +function makeTipResult() { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_result_menu.js b/browser/components/urlbar/tests/browser/browser_result_menu.js new file mode 100644 index 0000000000..b5df97f863 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_menu.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.resultMenu", true]], + }); +}); + +add_task(async function test_history() { + const TEST_URL = "https://remove.me/from_urlbar/"; + await PlacesTestUtils.addVisits(TEST_URL); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); + + const resultIndex = 1; + let result; + let startQuery = async () => { + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "from_urlbar", + }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal(result.url, TEST_URL, "Found the expected result"); + gURLBar.view.selectedRowIndex = resultIndex; + }; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.resultMenu.keyboardAccessible", false]], + }); + await startQuery(); + EventUtils.synthesizeKey("KEY_Tab"); + isnot( + UrlbarTestUtils.getSelectedElement(window), + UrlbarTestUtils.getButtonForResultIndex(window, "menu", resultIndex), + "Tab key skips over menu button with resultMenu.keyboardAccessible pref set to false" + ); + info( + "Checking that the mouse can still activate the menu button with resultMenu.keyboardAccessible = false" + ); + await UrlbarTestUtils.openResultMenu(window, { + byMouse: true, + resultIndex, + }); + gURLBar.view.resultMenu.hidePopup(); + await SpecialPowers.popPrefEnv(); + await startQuery(); + EventUtils.synthesizeKey("KEY_Tab"); + is( + UrlbarTestUtils.getSelectedElement(window), + UrlbarTestUtils.getButtonForResultIndex(window, "menu", resultIndex), + "Tab key doesn't skip over menu button with resultMenu.keyboardAccessible pref reset to true" + ); + + info("Checking that Space activates the menu button"); + await startQuery(); + await UrlbarTestUtils.openResultMenu(window, { + activationKey: " ", + }); + gURLBar.view.resultMenu.hidePopup(); + + info("Selecting Learn more item from the result menu"); + let tabOpenPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "awesome-bar-result-menu" + ); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L"); + info("Waiting for Learn more link to open in a new tab"); + await tabOpenPromise; + gBrowser.removeCurrentTab(); + + info("Restarting query in order to remove history entry via the menu"); + await startQuery(); + let promiseVisitRemoved = PlacesTestUtils.waitForNotification( + "page-removed", + events => events[0].url === TEST_URL + ); + let expectedResultCount = UrlbarTestUtils.getResultCount(window) - 1; + + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "R"); + const removeEvents = await promiseVisitRemoved; + Assert.ok( + removeEvents[0].isRemovedFromStore, + "isRemovedFromStore should be true" + ); + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == expectedResultCount, + "Waiting for the result to disappear" + ); + for (let i = 0; i < expectedResultCount; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.url, + TEST_URL, + "Should not find the test URL in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function test_remove_search_history() { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 1], + ], + }); + + let formHistoryValue = "foobar"; + await UrlbarTestUtils.formHistory.add([formHistoryValue]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [formHistoryValue], + "Should find form history after adding it" + ); + + let promiseRemoved = UrlbarTestUtils.formHistory.promiseChanged("remove"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let resultIndex = 1; + let count = UrlbarTestUtils.getResultCount(window); + for (; resultIndex < count; resultIndex++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + break; + } + } + Assert.ok(resultIndex < count, "Result found"); + + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "R", { + resultIndex, + }); + await promiseRemoved; + + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == count - 1, + "Waiting for the result to disappear" + ); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != UrlbarUtils.RESULT_SOURCE.HISTORY, + "Should not find the form history result in the remaining results" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + value: formHistoryValue, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [], + "Should not find form history after removing it" + ); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function firefoxSuggest() { + const url = "https://example.com/hey-there"; + const helpUrl = "https://example.com/help"; + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url, + isBlockable: true, + blockL10n: { id: "urlbar-result-menu-dismiss-firefox-suggest" }, + helpUrl, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + } + ), + ], + }); + + // Implement the provider's `onEngagement()` so it removes the result. + let onEngagementCallCount = 0; + provider.onEngagement = (isPrivate, state, queryContext, details) => { + onEngagementCallCount++; + queryContext.view.controller.removeResult(details.result); + }; + + UrlbarProvidersManager.registerProvider(provider); + + async function openResults() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.result.payload.url, + url, + "The result should be in the first row" + ); + } + + await openResults(); + let tabOpenPromise = BrowserTestUtils.waitForNewTab(gBrowser, helpUrl); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L", { + resultIndex: 0, + }); + info("Waiting for help URL to load in a new tab"); + await tabOpenPromise; + gBrowser.removeCurrentTab(); + + await openResults(); + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 0, + }); + + Assert.greater( + onEngagementCallCount, + 0, + "onEngagement() should have been called" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "There should be no results after blocking" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser/browser_result_onSelection.js b/browser/components/urlbar/tests/browser/browser_result_onSelection.js new file mode 100644 index 0000000000..18c16a3072 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_onSelection.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/1" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/2" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + ]; + + results[0].heuristic = true; + + let selectionCount = 0; + let provider = new UrlbarTestUtils.TestProvider({ + results, + priority: 1, + onSelection: (result, element) => { + selectionCount++; + }, + }); + UrlbarProvidersManager.registerProvider(provider); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + EventUtils.synthesizeKey("KEY_Tab", { + repeat: UrlbarPrefs.get("resultMenu") ? 5 : 3, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton, + "a one off button is selected" + ); + + Assert.equal( + selectionCount, + UrlbarPrefs.get("resultMenu") ? 6 : 4, + "Number of elements selected in the view." + ); + UrlbarProvidersManager.unregisterProvider(provider); +}); diff --git a/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js b/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js new file mode 100644 index 0000000000..22ec47403e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_retainedResultsOnFocus.js @@ -0,0 +1,435 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests retained results. +// When there is a pending search (user typed a search string and blurred +// without picking a result), on focus we should the search results again. + +async function checkPanelStatePersists(win, isOpen) { + // Check for popup events, we should not see any of them because the urlbar + // popup state should not change. This also ensures we don't cause flickering + // open/close actions. + function handler(event) { + Assert.ok(false, `Received unexpected event ${event.type}`); + } + win.gURLBar.addEventListener("popupshowing", handler); + win.gURLBar.addEventListener("popuphiding", handler); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + win.gURLBar.removeEventListener("popupshowing", handler); + win.gURLBar.removeEventListener("popuphiding", handler); + Assert.equal( + isOpen, + win.gURLBar.view.isOpen, + `check urlbar remains ${isOpen ? "open" : "closed"}` + ); +} + +async function checkOpensOnFocus(win, state) { + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + info("Check the keyboard shortcut."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(state.selectionStart, win.gURLBar.selectionStart); + Assert.equal(state.selectionEnd, win.gURLBar.selectionEnd); + + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + info("Focus with the mouse."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(state.selectionStart, win.gURLBar.selectionStart); + Assert.equal(state.selectionEnd, win.gURLBar.selectionEnd); +} + +async function checkDoesNotOpenOnFocus(win) { + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + info("Check the keyboard shortcut."); + let promiseState = checkPanelStatePersists(win, false); + win.document.getElementById("Browser:OpenLocation").doCommand(); + await promiseState; + win.gURLBar.blur(); + info("Focus with the mouse."); + promiseState = checkPanelStatePersists(win, false); + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + await promiseState; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", true]], + }); + // Add some history for the empty panel and autofill. + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "https://example.com/foo/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +async function test_window(win) { + for (let url of ["about:newtab", "about:home", "https://example.com/"]) { + // withNewTab may hang on preloaded pages, thus instead of waiting for load + // we just wait for the expected currentURI value. + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url, waitForLoad: false }, + async browser => { + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == url, + "Ensure we're on the expected page" + ); + + // In one case use a value that triggers autofill. + let autofill = url == "https://example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: autofill ? "ex" : "foo", + fireInputEvent: true, + }); + let { value, selectionStart, selectionEnd } = win.gURLBar; + if (!autofill) { + selectionStart = 0; + } + info("expected " + value + " " + selectionStart + " " + selectionEnd); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + + info("The panel should open when there's a search string"); + await checkOpensOnFocus(win, { value, selectionStart, selectionEnd }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + } + ); + } +} + +add_task(async function test_normalWindow() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await test_window(win); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_privateWindow() { + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await test_window(privateWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_tabSwitch() { + info("Check that switching tabs reopens the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "ex", + fireInputEvent: true, + }); + let { value, selectionStart, selectionEnd } = win.gURLBar; + Assert.equal(value, "example.com/", "Check autofill value"); + Assert.ok( + selectionStart > 0 && selectionEnd > selectionStart, + "Check autofill selection" + ); + + Assert.ok(win.gURLBar.focused, "The urlbar should be focused"); + let tab1 = win.gBrowser.selectedTab; + + async function check_autofill() { + // The urlbar code waits for both TabSelect and the focus change, thus + // we can't just wait for search completion here, we have to poll for a + // value. + await TestUtils.waitForCondition( + () => win.gURLBar.value == "example.com/", + "wait for autofill value" + ); + // Ensure stable results. + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(selectionStart, win.gURLBar.selectionStart); + Assert.equal(selectionEnd, win.gURLBar.selectionEnd); + } + + info("Open a new tab with the same search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "ex", + fireInputEvent: true, + }); + + info("Switch across tabs"); + for (let tab of win.gBrowser.tabs) { + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab); + }); + await check_autofill(); + } + + info("Close tab and check the view is open."); + await UrlbarTestUtils.promisePopupOpen(win, () => { + BrowserTestUtils.removeTab(tab2); + }); + await check_autofill(); + + info("Open a new tab with a different search"); + tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "xam", + fireInputEvent: true, + }); + + info("Switch to the first tab and check the panel remains open"); + let promiseState = checkPanelStatePersists(win, true); + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await promiseState; + await UrlbarTestUtils.promiseSearchComplete(win); + await check_autofill(); + + info("Switch to the second tab and check the panel remains open"); + promiseState = checkPanelStatePersists(win, true); + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + await promiseState; + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(win.gURLBar.value, "xam", "check value"); + Assert.equal(win.gURLBar.selectionStart, 3); + Assert.equal(win.gURLBar.selectionEnd, 3); + + info("autofill in tab2, switch to tab1, then back to tab2 with the mouse"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "e", + fireInputEvent: true, + }); + // Adjust selection start, we are using a different search string. + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await UrlbarTestUtils.promiseSearchComplete(win); + await check_autofill(); + tab2.click(); + selectionStart = 1; + await check_autofill(); + + info("Check we don't rerun a search if the shortcut is used on an open view"); + EventUtils.synthesizeKey("KEY_Backspace", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.ok(win.gURLBar.view.isOpen, "The view should be open"); + Assert.equal(win.gURLBar.value, "e", "The value should be the typed one"); + win.document.getElementById("Browser:OpenLocation").doCommand(); + // A search should not run here, so there's nothing to wait for. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.view.isOpen, "The view should be open"); + Assert.equal(win.gURLBar.value, "e", "The value should not change"); + + info( + "Tab switch from an empty search tab with unfocused urlbar to a tab with a search string and a focused urlbar" + ); + win.gURLBar.value = ""; + win.gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_tabSwitch_pageproxystate() { + info("Switching tabs on valid pageproxystate doesn't reopen."); + + info("Adding some visits for the empty panel"); + await PlacesTestUtils.addVisits([ + "https://example.com/", + "https://example.org/", + ]); + registerCleanupFunction(PlacesUtils.history.clear); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, "about:robots"); + let tab1 = win.gBrowser.selectedTab; + + info("Open a new tab and the empty search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + Assert.notEqual(result.url, "about:robots"); + + info("Switch to the first tab and start searching with DOWN"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + await checkPanelStatePersists(win, false); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Switcihng to the second tab should not reopen the search"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + }); + await checkPanelStatePersists(win, false); + + info("Switching to the first tab should not reopen the search"); + let promiseState = await checkPanelStatePersists(win, false); + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + await promiseState; + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_tabSwitch_emptySearch() { + info("Switching between empty-search tabs should not reopen the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Open the empty search"); + let tab1 = win.gBrowser.selectedTab; + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Open a new tab and the empty search"); + let tab2 = await BrowserTestUtils.openNewForegroundTab(win.gBrowser); + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.gURLBar.focus(); + // On Linux and Mac down moves caret to the end of the text unless it's + // there already. + win.gURLBar.selectionStart = win.gURLBar.selectionEnd = + win.gURLBar.value.length; + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + + info("Switching to the first tab should not reopen the view"); + await UrlbarTestUtils.promisePopupClose(win, async () => { + await BrowserTestUtils.switchTab(win.gBrowser, tab1); + }); + await checkPanelStatePersists(win, false); + + info("Switching to the second tab should not reopen the view"); + let promiseState = await checkPanelStatePersists(win, false); + await BrowserTestUtils.switchTab(win.gBrowser, tab2); + await promiseState; + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_pageproxystate_valid() { + info("Focusing on valid pageproxystate should not reopen the view."); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Search for a full url and confirm it with Enter"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "about:robots", + fireInputEvent: true, + }); + let loadedPromise = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await loadedPromise; + + Assert.ok(!win.gURLBar.focused, "The urlbar should not be focused"); + info("Focus the urlbar"); + win.document.getElementById("Browser:OpenLocation").doCommand(); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_allowAutofill() { + info("Check we respect allowAutofill from the last search"); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await UrlbarTestUtils.promisePopupOpen(win, async () => { + await selectAndPaste("e", win); + }); + Assert.equal(win.gURLBar.value, "e", "Should not autofill"); + let context = await win.gURLBar.lastQueryContextPromise; + Assert.equal(context.allowAutofill, false, "Check initial allowAutofill"); + await UrlbarTestUtils.promisePopupClose(win); + + await UrlbarTestUtils.promisePopupOpen(win, async () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + Assert.equal(win.gURLBar.value, "e", "Should not autofill"); + context = await win.gURLBar.lastQueryContextPromise; + Assert.equal(context.allowAutofill, false, "Check reopened allowAutofill"); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_clicks_after_autofill() { + info( + "Check that clickin on an autofilled input field doesn't requery, causing loss of the caret position" + ); + let win = await BrowserTestUtils.openNewBrowserWindow(); + info("autofill in tab2, switch to tab1, then back to tab2 with the mouse"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "e", + fireInputEvent: true, + }); + Assert.equal(win.gURLBar.value, "example.com/", "Should have autofilled"); + + // Check single click. + let input = win.gURLBar.inputField; + EventUtils.synthesizeMouse(input, 30, 10, {}, win); + // Wait a bit, in case of a mistake this would run a query, otherwise not. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.selectionStart < win.gURLBar.value.length); + Assert.equal(win.gURLBar.selectionStart, win.gURLBar.selectionEnd); + + // Check double click. + EventUtils.synthesizeMouse(input, 30, 10, { clickCount: 2 }, win); + // Wait a bit, in case of a mistake this would run a query, otherwise not. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Assert.ok(win.gURLBar.selectionStart < win.gURLBar.value.length); + Assert.ok(win.gURLBar.selectionEnd > win.gURLBar.selectionStart); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_revert.js b/browser/components/urlbar/tests/browser/browser_revert.js new file mode 100644 index 0000000000..b68ad0ff91 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_revert.js @@ -0,0 +1,33 @@ +// Test reverting the urlbar value with ESC after a tab switch. + +add_task(async function () { + registerCleanupFunction(PlacesUtils.history.clear); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://example.com", + }, + async function (browser) { + let originalValue = gURLBar.value; + let tab = gBrowser.selectedTab; + info("Put a typed value."); + gBrowser.userTypedValue = "foobar"; + info("Switch tabs."); + gBrowser.selectedTab = gBrowser.tabs[0]; + gBrowser.selectedTab = tab; + Assert.equal( + gURLBar.value, + "foobar", + "location bar displays typed value" + ); + + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Escape"); + Assert.equal( + gURLBar.value, + originalValue, + "ESC reverted the location bar value" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchFunction.js b/browser/components/urlbar/tests/browser/browser_searchFunction.js new file mode 100644 index 0000000000..0a272f9f01 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchFunction.js @@ -0,0 +1,278 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks the urlbar.search() function. + +"use strict"; + +const ALIAS = "@enginealias"; +let aliasEngine; + +add_setup(async function () { + // Run this in a new tab, to ensure all the locationchange notifications have + // fired. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await SearchTestUtils.installSearchExtension({ + keyword: ALIAS, + }); + aliasEngine = Services.search.getEngineByName("Example"); + + registerCleanupFunction(async function () { + BrowserTestUtils.removeTab(tab); + gURLBar.handleRevert(); + }); +}); + +// Calls search() with a normal, non-"@engine" search-string argument. +add_task(async function basic() { + gURLBar.blur(); + gURLBar.search("basic"); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("basic"); + + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Calls search() with an invalid "@engine" search engine alias so that the +// one-off search buttons are disabled. +add_task(async function searchEngineAlias() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search("@example") + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + UrlbarTestUtils.assertSearchMode(window, null); + await assertUrlbarValue("@example"); + + assertOneOffButtonsVisible(false); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Open the popup again (by doing another search) to make sure the one-off + // buttons are shown -- i.e., that we didn't accidentally break them. + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search("not an engine alias") + ); + await assertUrlbarValue("not an engine alias"); + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +add_task(async function searchRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.SEARCH) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: UrlbarSearchUtils.getDefaultEngine().name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + // Entry is "other" because we didn't pass searchModeEntry to search(). + entry: "other", + }); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function historyRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.HISTORY) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "other", + }); + assertOneOffButtonsVisible(true); + Assert.ok(!gURLBar.value, "The Urlbar has no value."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function historyRestrictionWithString() { + gURLBar.blur(); + // The leading and trailing spaces are intentional to verify that search() + // preserves them. + let searchString = " foo bar "; + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(`${UrlbarTokenizer.RESTRICT.HISTORY} ${searchString}`) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "other", + }); + // We don't use assertUrlbarValue here since we expect to open a local search + // mode. In those modes, we don't show a heuristic search result, which + // assertUrlbarValue checks for. + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + gURLBar.value, + searchString, + "The Urlbar value should be the search string." + ); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function tagRestriction() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => + gURLBar.search(UrlbarTokenizer.RESTRICT.TAG) + ); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + // Since tags are not a supported search mode, we should just insert the tag + // restriction token and not enter search mode. + await UrlbarTestUtils.assertSearchMode(window, null); + await assertUrlbarValue(`${UrlbarTokenizer.RESTRICT.TAG} `); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() twice with the same value. The popup should reopen. +add_task(async function searchTwice() { + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); + + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() during an IME composition. +add_task(async function searchIME() { + // First run a search. + gURLBar.blur(); + await UrlbarTestUtils.promisePopupOpen(window, () => gURLBar.search("test")); + ok(gURLBar.hasAttribute("focused"), "url bar is focused"); + await assertUrlbarValue("test"); + // Start composition. + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeComposition({ type: "compositionstart" }) + ); + + gURLBar.search("test"); + // Unfortunately there's no other way to check we don't open the view than to + // wait for it. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + ok(!UrlbarTestUtils.isPopupOpen(window), "The panel should still be closed"); + + await UrlbarTestUtils.promisePopupOpen(window, () => + EventUtils.synthesizeComposition({ type: "compositioncommitasis" }) + ); + + assertOneOffButtonsVisible(true); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() with an engine alias. +add_task(async function searchWithAlias() { + await UrlbarTestUtils.promisePopupOpen(window, async () => + gURLBar.search(`${ALIAS} test`, { + searchEngine: aliasEngine, + searchModeEntry: "topsites_urlbar", + }) + ); + Assert.ok(gURLBar.hasAttribute("focused"), "Urlbar is focused"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "topsites_urlbar", + }); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Calls search() and passes in a search engine without including a restriction +// token or engine alias in the search string. Simulates pasting into the newtab +// handoff field with search suggestions disabled. +add_task(async function searchEngineWithNoToken() { + await UrlbarTestUtils.promisePopupOpen(window, async () => + gURLBar.search("no-alias", { + searchEngine: aliasEngine, + searchModeEntry: "handoff", + }) + ); + + Assert.ok(gURLBar.hasAttribute("focused"), "Urlbar is focused"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "handoff", + }); + await assertUrlbarValue("no-alias"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Asserts that the one-off search buttons are or aren't visible. + * + * @param {boolean} visible + * True if they should be visible, false if not. + */ +function assertOneOffButtonsVisible(visible) { + Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + visible, + "Should show or not the one-off search buttons" + ); +} + +/** + * Asserts that the urlbar's input value is the given value. Also asserts that + * the first (heuristic) result in the popup is a search suggestion whose search + * query is the given value. + * + * @param {string} value + * The urlbar's expected value. + */ +async function assertUrlbarValue(value) { + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + + Assert.equal(gURLBar.value, value); + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 0, + "Should have at least one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have type search for the first result" + ); + // Strip search restriction token from value. + if (value[0] == UrlbarTokenizer.RESTRICT.SEARCH) { + value = value.substring(1).trim(); + } + Assert.equal( + result.searchParams.query, + value, + "Should have the correct query for the first result" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js b/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js new file mode 100644 index 0000000000..6fcde0882b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test checks that search values longer than + * SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH are not added to + * search history. + */ + +"use strict"; + +const { SearchSuggestionController } = ChromeUtils.importESModule( + "resource://gre/modules/SearchSuggestionController.sys.mjs" +); + +let gEngine; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + gEngine = Services.search.getEngineByName("Example"); + await UrlbarTestUtils.formHistory.clear(); + + registerCleanupFunction(async function () { + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function sanityCheckShortString() { + const shortString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) + .fill("a") + .join(""); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: shortString, + }); + let url = gEngine.getSubmission(shortString).uri.spec; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + url + ); + let addPromise = UrlbarTestUtils.formHistory.promiseChanged("add"); + EventUtils.synthesizeKey("VK_RETURN"); + await Promise.all([loadPromise, addPromise]); + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: gEngine.name }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + [shortString], + "Should find form history after adding it" + ); + + await UrlbarTestUtils.formHistory.clear(); +}); + +add_task(async function urlbar_checkLongString() { + const longString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1 + ) + .fill("a") + .join(""); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: longString, + }); + let url = gEngine.getSubmission(longString).uri.spec; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + url + ); + EventUtils.synthesizeKey("VK_RETURN"); + await loadPromise; + // There's nothing we can wait for, since addition should not be happening. + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 500)); + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: gEngine.name }) + ).map(entry => entry.value); + Assert.deepEqual(formHistory, [], "Should not find form history"); + + await UrlbarTestUtils.formHistory.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js b/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js new file mode 100644 index 0000000000..9f4558e6c9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js @@ -0,0 +1,274 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that user-defined aliases are replaced by the search mode indicator. + */ + +const ALIAS = "testalias"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +let defaultEngine, aliasEngine; + +add_setup(async function () { + defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + defaultEngine.alias = "@default"; + await SearchTestUtils.installSearchExtension({ + keyword: ALIAS, + }); + aliasEngine = Services.search.getEngineByName("Example"); +}); + +// An incomplete alias should not be replaced. +add_task(async function incompleteAlias() { + // Check that a non-fully typed alias is not replaced. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.slice(0, -1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Type a space just to make sure it's not replaced. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(" "); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.slice(0, -1) + " ", + "The typed value should be unchanged except for the space." + ); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete alias without a trailing space should not be replaced. +add_task(async function noTrailingSpace() { + let value = ALIAS; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete typed alias without a trailing space should not be replaced. +add_task(async function noTrailingSpace_typed() { + // Start by searching for the alias minus its last char. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.slice(0, -1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Now type the last char. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(ALIAS.slice(-1)); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS, + "The typed value should be the full alias." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// A complete alias with a trailing space should be replaced. +add_task(async function trailingSpace() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.ok(!gURLBar.value, "The urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// A complete alias should be replaced after typing a space. +add_task(async function trailingSpace_typed() { + for (let spaces of TEST_SPACES) { + if (spaces.length != 1) { + continue; + } + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + + // We need to wait for two searches: The first enters search mode, the second + // does the search in search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(spaces); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.ok(!gURLBar.value, "The urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// A complete alias with a trailing space should be replaced, and the query +// after the trailing space should be the new value of the input. +add_task(async function trailingSpace_query() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces + "query", + }); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + "query", + "The urlbar value should be the query." + ); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +add_task(async function () { + info("Test search mode when typing an alias after selecting one-off button"); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + const selectedEngine = oneOffs.selectedButton.engine; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: selectedEngine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Type a search engine alias and query"); + const inputString = "@default query"; + inputString.split("").forEach(c => EventUtils.synthesizeKey(c)); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + gURLBar.value, + inputString, + "Alias and query is inputed correctly to the urlbar" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: selectedEngine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + }); + + // When starting typing, as the search mode is confirmed, the one-off + // selection is removed. + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); + + // Clean up + gURLBar.value = ""; + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function () { + info( + "Test search mode after removing current search mode when multiple aliases are written" + ); + + info("Open the result popup with multiple aliases"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@default testalias @default", + }); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: defaultEngine.name, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + "testalias @default", + "The value on the urlbar is correct" + ); + + info("Exit search mode by clicking"); + const indicator = gURLBar.querySelector("#urlbar-search-mode-indicator"); + EventUtils.synthesizeMouseAtCenter(indicator, { type: "mouseover" }, window); + const closeButton = gURLBar.querySelector( + "#urlbar-search-mode-indicator-close" + ); + const searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "typed", + }); + Assert.equal(gURLBar.value, "@default", "The value on the urlbar is correct"); + + // Clean up + gURLBar.value = ""; + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js b/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js new file mode 100644 index 0000000000..3186d96b92 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that autofill is cleared if a remote search mode is entered but still + * works for local search modes. + */ + +"use strict"; + +add_setup(async function () { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + } + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + let defaultEngine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(defaultEngine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests that autofill is cleared when entering a remote search mode and that +// autofill doesn't happen when in that mode. +add_task(async function remote() { + info("Sanity check: we autofill in a normal search."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + info("Enter remote search mode and check autofill is cleared."); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "ex", "Urlbar contains the typed string."); + + info("Continue typing and check that we're not autofilling."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(!details.autofill, "We're not autofilling."); + Assert.equal(gURLBar.value, "exa", "Urlbar contains the typed string."); + + info("Exit remote search mode and check that we now autofill."); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the typed string." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that autofill works as normal when entering and when in a local search +// mode. +add_task(async function local() { + info("Sanity check: we autofill in a normal search."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + info("Enter local search mode and check autofill is preserved."); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + + info("Continue typing and check that we're autofilling."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the autofilled URL." + ); + + info("Exit local search mode and check that nothing has changed."); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "We're autofilling."); + Assert.equal( + gURLBar.value, + "example.com/", + "Urlbar contains the typed string." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js b/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js new file mode 100644 index 0000000000..d037c77bbb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_clickLink.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is exited after clicking a link and loading a page in + * the current tab. + */ + +"use strict"; + +const LINK_PAGE_URL = + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/dummy_page.html"; + +// Opens a new tab containing a link, enters search mode, and clicks the link. +// Uses a variety of search strings and link hrefs in order to hit different +// branches in setURI. Search mode should be exited in all cases, and the href +// in the link should be opened. +add_task(async function clickLink() { + for (let test of [ + // searchString, href to use in the link + [LINK_PAGE_URL, LINK_PAGE_URL], + [LINK_PAGE_URL, "http://www.example.com/"], + ["test", LINK_PAGE_URL], + ["test", "http://www.example.com/"], + [null, LINK_PAGE_URL], + [null, "http://www.example.com/"], + ]) { + await doClickLinkTest(...test); + } +}); + +async function doClickLinkTest(searchString, href) { + info( + "doClickLinkTest with args: " + + JSON.stringify({ + searchString, + href, + }) + ); + + await BrowserTestUtils.withNewTab(LINK_PAGE_URL, async () => { + if (searchString) { + // Do a search with the search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + Assert.ok( + gBrowser.selectedBrowser.userTypedValue, + "userTypedValue should be defined" + ); + } else { + // Open top sites. + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + Assert.strictEqual( + gBrowser.selectedBrowser.userTypedValue, + null, + "userTypedValue should be null" + ); + } + + // Enter search mode and then close the popup so we can click the link in + // the page. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + + // Add a link to the page and click it. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await ContentTask.spawn(gBrowser.selectedBrowser, href, async cHref => { + let link = this.content.document.createElement("a"); + link.textContent = "Click me"; + link.href = cHref; + this.content.document.body.append(link); + link.click(); + }); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + href, + "Should have loaded the href URL" + ); + + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js b/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js new file mode 100644 index 0000000000..f5eab77789 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that we exit search mode when the search mode engine is removed. + */ + +"use strict"; + +// Tests that we exit search mode in the active tab when the search mode engine +// is removed. +add_task(async function activeTab() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(window); + // Sanity check: we are in the correct search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + await extension.unload(); + // Check that we are no longer in search mode. + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that we exit search mode in a background tab when the search mode +// engine is removed. +add_task(async function backgroundTab() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(window); + let tab1 = gBrowser.selectedTab; + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Sanity check: tab1 is still in search mode. + await BrowserTestUtils.switchTab(gBrowser, tab1); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Switch back to tab2 so tab1 is in the background when the engine is + // removed. + await BrowserTestUtils.switchTab(gBrowser, tab2); + // tab2 shouldn't be in search mode. + await UrlbarTestUtils.assertSearchMode(window, null); + await extension.unload(); + + // tab1 should have exited search mode. + await BrowserTestUtils.switchTab(gBrowser, tab1); + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(tab2); +}); + +// Tests that we exit search mode in a background window when the search mode +// engine is removed. +add_task(async function backgroundWindow() { + let extension = await SearchTestUtils.installSearchExtension( + {}, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + let win1 = window; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win1, + value: "ex", + }); + await UrlbarTestUtils.enterSearchMode(win1); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + // Sanity check: win1 is still in search mode. + win1.focus(); + await UrlbarTestUtils.assertSearchMode(win1, { + engineName: engine.name, + entry: "oneoff", + }); + + // Switch back to win2 so win1 is in the background when the engine is + // removed. + win2.focus(); + // win2 shouldn't be in search mode. + await UrlbarTestUtils.assertSearchMode(win2, null); + await extension.unload(); + + // win1 should not be in search mode. + win1.focus(); + await UrlbarTestUtils.assertSearchMode(win1, null); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js b/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js new file mode 100644 index 0000000000..0e9471280e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js @@ -0,0 +1,217 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that results with hostnames other than the search mode engine are not + * shown. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + ["browser.urlbar.autoFill", false], + // Special prefs for remote tabs. + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Note that the result domain is subdomain.example.ca. We still expect to + // match with example.com results because we ignore subdomains and the public + // suffix in this check. + await SearchTestUtils.installSearchExtension( + { + search_url: "https://subdomain.example.ca/", + }, + { setAsDefault: true } + ); + let engine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(engine, 0); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "Nightly on MacBook-Pro", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "https://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + { + type: "tab", + title: "Test Remote 2", + url: "https://example-2.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function basic() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + "We have three results" + ); + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The second result is a remote tab." + ); + let thirdResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + thirdResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The third result is a remote tab." + ); + + await UrlbarTestUtils.enterSearchMode(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "We have two results. The second remote tab result is excluded despite matching the search string." + ); + firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The second result is a remote tab." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// For engines with an invalid TLD, we filter on the entire domain. +add_task(async function malformedEngine() { + await SearchTestUtils.installSearchExtension({ + name: "TestMalformed", + search_url: "https://example.foobar/", + }); + let badEngine = Services.search.getEngineByName("TestMalformed"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 4, + "We have four results" + ); + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + let secondResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The second result is the tab-to-search onboarding result for our malformed engine." + ); + let thirdResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal( + thirdResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The third result is a remote tab." + ); + let fourthResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 3); + Assert.equal( + fourthResult.type, + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + "The fourth result is a remote tab." + ); + + await UrlbarTestUtils.enterSearchMode(window, { + engineName: badEngine.name, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We only have one result." + ); + firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(firstResult.heuristic, "The first result is heuristic."); + Assert.equal( + firstResult.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is the heuristic search result." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js b/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js new file mode 100644 index 0000000000..bd8f00a512 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests heuristic results in search mode. + */ + +"use strict"; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add a new mock default engine so we don't hit the network. + await SearchTestUtils.installSearchExtension( + { name: "Test" }, + { setAsDefault: true } + ); + + // Add one bookmark we'll use below. + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/bookmark", + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Enters search mode with no results. +add_task(async function noResults() { + // Do a search that doesn't match our bookmark and enter bookmark search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "doesn't match anything", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 0, + "Zero results since no bookmark matches" + ); + + // Press enter. Nothing should happen. + let loadPromise = waitForLoadOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + let loadEvent = await loadPromise; + Assert.ok(!loadEvent, "Nothing should have loaded"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Enters a local search mode (bookmarks) with a matching result. No heuristic +// should be present. +add_task(async function localNoHeuristic() { + // Do a search that matches our bookmark and enter bookmarks search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bookmark", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Result source should be BOOKMARKS" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "http://example.com/bookmark", + "Result URL is our bookmark URL" + ); + Assert.ok(!result.heuristic, "Result should not be heuristic"); + + // Press enter. Nothing should happen. + let loadPromise = waitForLoadOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + let loadEvent = await loadPromise; + Assert.ok(!loadEvent, "Nothing should have loaded"); + + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Enters a local search mode (bookmarks) with a matching autofill result. The +// result should be the heuristic. +add_task(async function localAutofill() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search that autofills our bookmark's origin and enter bookmarks + // search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Result source should be HISTORY" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "http://example.com/", + "Result URL is our bookmark's origin" + ); + Assert.ok(result.heuristic, "Result should be heuristic"); + Assert.ok(result.autofill, "Result should be autofill"); + + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Result source should be BOOKMARKS" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.URL, + "Result type should be URL" + ); + Assert.equal( + result.url, + "http://example.com/bookmark", + "Result URL is our bookmark URL" + ); + + // Press enter. Our bookmark's origin should be loaded. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + "http://example.com/", + "Bookmark's origin should have loaded" + ); + }); +}); + +// Enters a remote engine search mode. There should be a heuristic. +add_task(async function remote() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search and enter search mode with our test engine. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "remote", + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Test", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should be one result" + ); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.SEARCH, + "Result source should be SEARCH" + ); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Result type should be SEARCH" + ); + Assert.ok(result.searchParams, "searchParams should be present"); + Assert.equal( + result.searchParams.engine, + "Test", + "searchParams.engine should be our test engine" + ); + Assert.equal( + result.searchParams.query, + "remote", + "searchParams.query should be our query" + ); + Assert.ok(result.heuristic, "Result should be heuristic"); + + // Press enter. The engine's SERP should load. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + "https://example.com/?q=remote", + "Engine's SERP should have loaded" + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js b/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js new file mode 100644 index 0000000000..357a5d17f9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js @@ -0,0 +1,377 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests interactions with the search mode indicator. See browser_oneOffs.js for + * more coverage. + */ + +const TEST_QUERY = "test string"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; + +// These need to have different domains because otherwise new tab and/or +// activity stream collapses them. +const TOP_SITES_URLS = [ + "http://top-site-0.com/", + "http://top-site-1.com/", + "http://top-site-2.com/", +]; + +let suggestionsEngine; +let defaultEngine; + +add_setup(async function () { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + }); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + defaultEngine = Services.search.getEngineByName("Example"); + await Services.search.moveEngine(suggestionsEngine, 0); + + // Set our top sites. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.default.sites", + TOP_SITES_URLS.join(","), + ], + ], + }); + await updateTopSites(sites => + ObjectUtils.deepEqual( + sites.map(s => s.url), + TOP_SITES_URLS + ) + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); +}); + +async function verifySearchModeResultsAdded(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + "There should be three results." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.searchParams.engine, + suggestionsEngine.name, + "The first result should be a search result for our suggestion engine." + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.searchParams.suggestion, + `${TEST_QUERY}foo`, + "The second result should be a suggestion result." + ); + Assert.equal( + result.searchParams.engine, + suggestionsEngine.name, + "The second result should be a search result for our suggestion engine." + ); +} + +async function verifySearchModeResultsRemoved(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "There should only be one result." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.searchParams.engine, + defaultEngine.name, + "The first result should be a search result for our default engine." + ); +} + +async function verifyTopSitesResultsAdded(window) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + TOP_SITES_URLS.length, + "Expected number of top sites results" + ); + for (let i = 0; i < TOP_SITES_URLS; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + result.url, + TOP_SITES_URLS[i], + `Expected top sites result URL at index ${i}` + ); + } +} + +// Tests that the indicator is removed when backspacing at the beginning of +// the search string. +add_task(async function backspace() { + // View open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + + // View open, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + + // View closed, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is now open."); + + // View closed, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function escapeOnInitialPage() { + info("Tests the indicator's interaction with the ESC key"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed."); + + let oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.ok(!gURLBar.value, "Urlbar value is empty."); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +add_task(async function escapeOnBrowsingPage() { + info("Tests the indicator's interaction with the ESC key on browsing page"); + + await BrowserTestUtils.withNewTab("http://example.com", async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed."); + + const oneOffs = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons(true); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs[0].engine.name, + entry: "oneoff", + }); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed.")); + Assert.equal( + gURLBar.value, + "example.com", + "Urlbar value indicates the browsing page." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + }); +}); + +// Tests that the indicator is removed when its close button is clicked. +add_task(async function click_close() { + // Clicking close with the view open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await verifySearchModeResultsRemoved(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking close with the view open, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is open."); + await verifyTopSitesResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking close with the view closed, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + await UrlbarTestUtils.enterSearchMode(window); + await verifySearchModeResultsAdded(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Urlbar view is closed."); + + // Clicking close with the view closed, no string (i.e., top sites). + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.enterSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Urlbar view is closed."); +}); + +// Tests that Accel+K enters search mode with the default engine. Also tests +// that Accel+K highlights the typed search string. +add_task(async function keyboard_shortcut() { + const query = "test query"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + "The search string is not highlighted." + ); + EventUtils.synthesizeKey("k", { accelKey: true }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: defaultEngine.name, + entry: "shortcut", + }); + Assert.equal(gURLBar.value, query, "The search string was not cleared."); + Assert.equal(gURLBar.selectionStart, 0); + Assert.equal( + gURLBar.selectionEnd, + query.length, + "The search string is highlighted." + ); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that the Tools:Search menu item enters search mode with the default +// engine. Also tests that Tools:Search highlights the typed search string. +add_task(async function menubar_item() { + const query = "test query 2"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + "The search string is not highlighted." + ); + let command = window.document.getElementById("Tools:Search"); + command.doCommand(); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: defaultEngine.name, + entry: "shortcut", + }); + Assert.equal(gURLBar.value, query, "The search string was not cleared."); + Assert.equal(gURLBar.selectionStart, 0); + Assert.equal( + gURLBar.selectionEnd, + query.length, + "The search string is highlighted." + ); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that entering search mode invalidates pageproxystate and that +// pageproxystate remains invalid after exiting search mode. +add_task(async function invalidate_pageproxystate() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.equal(gURLBar.getAttribute("pageproxystate"), "valid"); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Entering search mode should clear pageproxystate." + ); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Pageproxystate should still be invalid after exiting search mode." + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js b/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js new file mode 100644 index 0000000000..acfb60922d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_indicator_clickthrough.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check clicking on the search mode indicator when the urlbar is not focused puts + * focus in the urlbar. + */ + +add_task(async function test() { + // Avoid remote connections. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + + await BrowserTestUtils.withNewTab("about:robots", async browser => { + // View open, with string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + const indicator = document.getElementById("urlbar-search-mode-indicator"); + Assert.ok(!BrowserTestUtils.is_visible(indicator)); + const indicatorCloseButton = document.getElementById( + "urlbar-search-mode-indicator-close" + ); + Assert.ok(!BrowserTestUtils.is_visible(indicatorCloseButton)); + const labelBox = document.getElementById("urlbar-label-box"); + Assert.ok(!BrowserTestUtils.is_visible(labelBox)); + + await UrlbarTestUtils.enterSearchMode(window); + Assert.ok(BrowserTestUtils.is_visible(indicator)); + Assert.ok(BrowserTestUtils.is_visible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.is_visible(labelBox)); + + info("Blur the urlbar"); + gURLBar.blur(); + Assert.ok(BrowserTestUtils.is_visible(indicator)); + Assert.ok(BrowserTestUtils.is_visible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.is_visible(labelBox)); + Assert.notEqual( + document.activeElement, + gURLBar.inputField, + "URL Bar should not be focused" + ); + + info("Focus the urlbar clicking on the indicator"); + EventUtils.synthesizeMouseAtCenter(indicator, {}); + Assert.ok(BrowserTestUtils.is_visible(indicator)); + Assert.ok(BrowserTestUtils.is_visible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.is_visible(labelBox)); + Assert.equal( + document.activeElement, + gURLBar.inputField, + "URL Bar should be focused" + ); + + info("Leave search mode clicking on the close button"); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + Assert.ok(!BrowserTestUtils.is_visible(indicator)); + Assert.ok(!BrowserTestUtils.is_visible(indicatorCloseButton)); + Assert.ok(!BrowserTestUtils.is_visible(labelBox)); + }); + + await BrowserTestUtils.withNewTab("about:robots", async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + const indicator = document.getElementById("urlbar-search-mode-indicator"); + Assert.ok(!BrowserTestUtils.is_visible(indicator)); + const indicatorCloseButton = document.getElementById( + "urlbar-search-mode-indicator-close" + ); + Assert.ok(!BrowserTestUtils.is_visible(indicatorCloseButton)); + + await UrlbarTestUtils.enterSearchMode(window); + Assert.ok(BrowserTestUtils.is_visible(indicator)); + Assert.ok(BrowserTestUtils.is_visible(indicatorCloseButton)); + + info("Blur the urlbar"); + gURLBar.blur(); + Assert.ok(BrowserTestUtils.is_visible(indicator)); + Assert.ok(BrowserTestUtils.is_visible(indicatorCloseButton)); + Assert.notEqual( + document.activeElement, + gURLBar.inputField, + "URL Bar should not be focused" + ); + + info("Leave search mode clicking on the close button while unfocussing"); + await UrlbarTestUtils.exitSearchMode(window, { + clickClose: true, + waitForSearch: false, + }); + Assert.ok(!BrowserTestUtils.is_visible(indicator)); + Assert.ok(!BrowserTestUtils.is_visible(indicatorCloseButton)); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js new file mode 100644 index 0000000000..74a2a3caba --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js @@ -0,0 +1,459 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests action text shown on heuristic and search suggestions when keyboard + * navigating local one-off buttons. + */ + +"use strict"; + +const DEFAULT_ENGINE_NAME = "Test"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; + +let engine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.urlbar.shortcuts.quickactions", false], + ], + }); + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + + await PlacesUtils.history.clear(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function localOneOff() { + info("Type some text, select a local one-off, check heuristic action"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "query", + }); + Assert.ok(UrlbarTestUtils.getResultCount(window) > 1, "Sanity check results"); + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "A local one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.is_visible(result.element.action), + "The heuristic action should be visible" + ); + let [actionHistory, actionBookmarks] = await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-bookmarks" }, + ]); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + + info("Move to an engine one-off and check heuristic action"); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.ok( + oneOffButtons.selectedButton.engine, + "A one-off search button should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + BrowserTestUtils.is_visible(result.element.action), + "The heuristic action should be visible" + ); + Assert.ok( + result.displayed.action.includes(oneOffButtons.selectedButton.engine.name), + "Check the heuristic action" + ); + Assert.equal( + result.image, + oneOffButtons.selectedButton.engine.iconURI.spec, + "Check the heuristic icon" + ); + + info("Move again to a local one-off, deselect and reselect the heuristic"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "A local one-off button should be selected" + ); + Assert.equal( + result.displayed.action, + actionBookmarks, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/bookmark.svg", + "Check the heuristic icon" + ); + + info( + "Select the next result, then reselect the heuristic, check it's a search with the default engine" + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "the heuristic result should not be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.searchParams.engine, engine.name); + Assert.ok( + result.displayed.action.includes(engine.name), + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the heuristic icon" + ); +}); + +add_task(async function localOneOff_withVisit() { + info("Type a url, select a local one-off, check heuristic action"); + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://mozilla.org/"); + await PlacesTestUtils.addVisits("https://other.mozilla.org/"); + } + const searchString = "mozilla.org"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + Assert.ok(UrlbarTestUtils.getResultCount(window) > 1, "Sanity check results"); + let oneOffButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + + let [actionHistory, actionTabs, actionBookmarks] = + await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-tabs" }, + { id: "urlbar-result-action-search-bookmarks" }, + ]); + + info("Alt UP to select the history one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "The history one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.is_visible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.querySelector(".urlbarView-title").textContent, + searchString, + "Check that the result title has been replaced with the search string." + ); + + info("Alt UP to select the tabs one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The tabs one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.is_visible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionTabs, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/tab.svg", + "Check the heuristic icon" + ); + + info("Alt UP to select the bookmarks one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The bookmarks one-off button should be selected" + ); + Assert.ok( + BrowserTestUtils.is_visible(result.element.action), + "The heuristic action should be visible" + ); + Assert.equal( + result.displayed.action, + actionBookmarks, + "Check the heuristic action" + ); + Assert.equal( + result.image, + "chrome://browser/skin/bookmark.svg", + "Check the heuristic icon" + ); + + info( + "Select the next result, then reselect the heuristic, check it's a visit" + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "the heuristic result should not be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", {}); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "the heuristic result should be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + oneOffButtons.selectedButton, + null, + "No one-off button should be selected" + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal( + result.displayed.url, + result.result.payload.displayUrl, + "Check the heuristic action" + ); + Assert.notEqual( + result.image, + "chrome://browser/skin/history.svg", + "Check the heuristic icon" + ); + row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.querySelector(".urlbarView-title").textContent, + result.result.payload.title || `https://${searchString}`, + "Check that the result title has been restored to the fixed-up URI." + ); + + await PlacesUtils.history.clear(); +}); + +add_task(async function localOneOff_suggestion() { + info("Type some text, select the first suggestion, then a local one-off"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "query", + }); + let count = UrlbarTestUtils.getResultCount(window); + Assert.ok(count > 1, "Sanity check results"); + let result = null; + let suggestionIndex = -1; + for (let i = 1; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = await UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + suggestionIndex = i; + break; + } + } + Assert.ok( + result.searchParams.suggestion, + "Should have selected a search suggestion" + ); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + await UrlbarTestUtils.getSelectedRowIndex(window), + suggestionIndex, + "the suggestion should still be selected" + ); + + let [actionHistory] = await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + ]); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, suggestionIndex); + Assert.equal( + result.displayed.action, + actionHistory, + "Check the search suggestion action changed to local one-off" + ); + // Like in the normal engine one-offs case, we don't replace the favicon. + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); + + info("DOWN to select the next suggestion"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + suggestionIndex + 1 + ); + Assert.ok( + result.searchParams.suggestion, + "Should have selected a search suggestion" + ); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); + + info("UP back to the previous suggestion"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, suggestionIndex); + Assert.ok( + result.displayed.action.includes(result.searchParams.engine), + "Check the search suggestion action" + ); + Assert.equal( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the suggestion icon" + ); +}); + +add_task(async function localOneOff_shortcut() { + info("Select a search shortcut, then a local one-off"); + + await PlacesUtils.history.clear(); + // Enough vists to get this site into Top Sites. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let count = UrlbarTestUtils.getResultCount(window); + Assert.ok(count > 1, "Sanity check results"); + let result = null; + let shortcutIndex = -1; + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = await UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.keyword + ) { + shortcutIndex = i; + break; + } + } + Assert.ok(result.searchParams.keyword, "Should have selected a shortcut"); + let shortcutEngine = result.searchParams.engine; + + info("Alt UP to select the last local one-off."); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + Assert.equal( + await UrlbarTestUtils.getSelectedRowIndex(window), + shortcutIndex, + "the shortcut should still be selected" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, shortcutIndex); + Assert.equal( + result.displayed.action, + "", + "Check the shortcut action is empty" + ); + Assert.equal( + result.searchParams.engine, + shortcutEngine, + "Check the shortcut engine" + ); + Assert.ok( + result.displayed.title.includes(shortcutEngine), + "Check the shortcut title" + ); + Assert.notEqual( + result.image, + "chrome://global/skin/icons/search-glass.svg", + "Check the icon was not replaced" + ); + + await UrlbarTestUtils.exitSearchMode(window); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js b/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js new file mode 100644 index 0000000000..e5a3eb848a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_newWindow.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests immediately entering search mode in a new window and then exiting it. +// No errors should be thrown and search mode should be exited successfully. + +"use strict"; + +add_task(async function escape() { + await doTest(win => + EventUtils.synthesizeKey("KEY_Escape", { repeat: 2 }, win) + ); +}); + +add_task(async function backspace() { + await doTest(win => EventUtils.synthesizeKey("KEY_Backspace", {}, win)); +}); + +async function doTest(exitSearchMode) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + // Press accel+K to enter search mode. + await UrlbarTestUtils.promisePopupOpen(win, () => + EventUtils.synthesizeKey("k", { accelKey: true }, win) + ); + await UrlbarTestUtils.assertSearchMode(win, { + engineName: Services.search.defaultEngine.name, + isGeneralPurposeEngine: true, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isPreview: false, + entry: "shortcut", + }); + + // Exit search mode. + await exitSearchMode(win); + await UrlbarTestUtils.assertSearchMode(win, null); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js b/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js new file mode 100644 index 0000000000..9ecc5573fc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js @@ -0,0 +1,290 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests entering search mode and there are no results in the view. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +add_setup(async function () { + // In order to open the view without any results, we need to be in search mode + // with an empty search string so that no heuristic result is shown, and the + // empty search must yield zero additional results. We'll enter search mode + // using the bookmarks one-off, and first we'll delete all bookmarks so that + // there are no results. + await PlacesUtils.bookmarks.eraseEverything(); + + // Also clear history so that using the alias of our test engine doesn't + // inadvertently return any history results due to bug 1658646. + await PlacesUtils.history.clear(); + + // Add a top site so we're guaranteed the view has at least one result to + // show initially with an empty search. Otherwise the view won't even open. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.default.sites", + "http://example.com/", + ], + ], + }); + await updateTopSites(sites => sites.length); +}); + +// Basic test for entering search mode with no results. +add_task(async function basic() { + await withNewWindow(async win => { + // Do an empty search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "", + }); + + // Initially there should be at least the top site we added above. + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present initially" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown again. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// When the urlbar is in search mode, has no results, and is not focused, +// focusing it should auto-open the view. +add_task(async function autoOpen() { + await withNewWindow(async win => { + // Do an empty search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "", + }); + + // Initially there should be at least the top site we added above. + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present initially" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Blur the urlbar. + win.gURLBar.blur(); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + + // Click the urlbar. + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Still zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel still has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown again. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// When the urlbar is in search mode, the user backspaces over the final char +// (but remains in search mode), and there are no results, the view should +// remain open. +add_task(async function backspaceRemainOpen() { + await withNewWindow(async win => { + // Do a one-char search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "At least the heuristic result should be shown" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "Panel has results, therefore should not have noresults attribute" + ); + + // Enter search mode by clicking the bookmarks one-off. + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // The heursitic should not be shown since we don't show it in local search + // modes. + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "No results should be present" + ); + Assert.ok( + win.gURLBar.panel.hasAttribute("noresults"), + "Panel has no results, therefore should have noresults attribute" + ); + + // Backspace. The search string will now be empty. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Backspace", {}, win); + await searchPromise; + Assert.ok(UrlbarTestUtils.isPopupOpen(win), "View remains open"); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + UrlbarTestUtils.getResultCount(win), + 0, + "Zero results since no bookmarks exist" + ); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + + // Exit search mode by backspacing. The top sites should be shown. + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + Assert.greater( + UrlbarTestUtils.getResultCount(win), + 0, + "Top sites should be present again" + ); + Assert.ok( + !win.gURLBar.panel.hasAttribute("noresults"), + "noresults attribute should be absent again" + ); + + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +// Types a search alias and then a space to enter search mode, with no results. +// The one-offs should be shown. +add_task(async function spaceToEnterSearchMode() { + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + engine.alias = "@test"; + + await withNewWindow(async win => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: engine.alias, + }); + + // We need to wait for two searches: The first enters search mode, the + // second does the search in search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey(" ", {}, win); + await searchPromise; + + Assert.equal(UrlbarTestUtils.getResultCount(win), 0, "Zero results"); + Assert.equal( + win.gURLBar.panel.getAttribute("noresults"), + "true", + "Panel has no results, therefore should have noresults attribute" + ); + await UrlbarTestUtils.assertSearchMode(win, { + engineName: engine.name, + entry: "typed", + }); + this.Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + await UrlbarTestUtils.exitSearchMode(win, { backspace: true }); + await UrlbarTestUtils.promisePopupClose(win); + }); +}); + +/** + * Opens a new window, waits for it to load, calls a callback, and closes the + * window. We use a new window in each task so that the view starts with a + * blank slate each time. + * + * @param {Function} callback + * Will be called as: callback(newWindow) + */ +async function withNewWindow(callback) { + // Start in a new window so we have a blank slate. + let win = await BrowserTestUtils.openNewBrowserWindow(); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js b/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js new file mode 100644 index 0000000000..1ba0d3283b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests one-off search button behavior with search mode. + */ + +const TEST_ENGINE_NAME = "test engine"; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: "@test", + }); +}); + +add_task(async function test() { + info("Test no one-off buttons are selected when entering search mode"); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + + info("Enter search mode"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: TEST_ENGINE_NAME, + }); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "oneoff", + }); + ok(!oneOffs.selectedButton, "There is no selected one-off button"); +}); + +add_task(async function () { + info( + "Test the status of the selected one-off button when exiting search mode with backspace" + ); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs.selectedButton.engine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Exit from search mode"); + await UrlbarTestUtils.exitSearchMode(window); + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); +}); + +add_task(async function () { + info( + "Test the status of the selected one-off button when exiting search mode with clicking close button" + ); + + info("Open the result popup"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Select one of one-off button"); + const oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(oneOffs.selectedButton, "There is a selected one-off button"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffs.selectedButton.engine.name, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "oneoff", + isPreview: true, + }); + + info("Exit from search mode"); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + ok(!oneOffs.selectedButton, "There is no any selected one-off button"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js b/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js new file mode 100644 index 0000000000..ac45b3e5c7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_pickResult.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is exited after picking a result. + */ + +"use strict"; + +const BOOKMARK_URL = "http://www.example.com/browser_searchMode_pickResult.js"; + +add_setup(async function () { + // Add a bookmark so we can enter bookmarks search mode and pick it. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: BOOKMARK_URL, + }); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +// Opens a new tab, enters search mode, does a search for our test bookmark, and +// picks it. Uses a variety of initial URLs and search strings in order to hit +// different branches in setURI. Search mode should be exited in all cases. +add_task(async function pickResult() { + for (let test of [ + // initialURL, searchString + ["about:blank", BOOKMARK_URL], + ["about:blank", new URL(BOOKMARK_URL).origin], + ["about:blank", new URL(BOOKMARK_URL).pathname], + [BOOKMARK_URL, BOOKMARK_URL], + [BOOKMARK_URL, new URL(BOOKMARK_URL).origin], + [BOOKMARK_URL, new URL(BOOKMARK_URL).pathname], + ]) { + await doPickResultTest(...test); + } +}); + +async function doPickResultTest(initialURL, searchString) { + info( + "doPickResultTest with args: " + + JSON.stringify({ + initialURL, + searchString, + }) + ); + + await BrowserTestUtils.withNewTab(initialURL, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Arrow down to the bookmark result. + let firstResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + if (!firstResult.heuristic) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + let foundResult = false; + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.url == BOOKMARK_URL) { + foundResult = true; + break; + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.ok(foundResult, "The bookmark result should have been found"); + + // Press enter. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + Assert.equal( + gBrowser.currentURI.spec, + BOOKMARK_URL, + "Should have loaded the bookmarked URL" + ); + + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_preview.js b/browser/components/urlbar/tests/browser/browser_searchMode_preview.js new file mode 100644 index 0000000000..19df744663 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_preview.js @@ -0,0 +1,489 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search mode preview. + */ + +"use strict"; + +const TEST_ENGINE_NAME = "Test"; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: "@test", + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +/** + * @param {Node} button + * A one-off button. + * @param {boolean} [isPreview] + * Whether the expected search mode should be a preview. Defaults to true. + * @returns {object} + * The search mode object expected when that one-off is selected. + */ +function getExpectedSearchMode(button, isPreview = true) { + let expectedSearchMode = { + entry: "oneoff", + isPreview, + }; + if (button.engine) { + expectedSearchMode.engineName = button.engine.name; + let engine = Services.search.getEngineByName(button.engine.name); + if (engine.isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } else { + expectedSearchMode.source = button.source; + } + + return expectedSearchMode; +} + +// Tests that cycling through token alias engines enters search mode preview. +add_task(async function tokenAlias() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + let result; + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + let expectedSearchMode = { + engineName: result.searchParams.engine, + isPreview: true, + entry: "keywordoffer", + }; + let engine = Services.search.getEngineByName(result.searchParams.engine); + if (engine.isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + } + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + // Test that we are in confirmed search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: result.searchParams.engine, + entry: "keywordoffer", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that starting to type a query exits search mode preview in favour of +// full search mode. +add_task(async function startTyping() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("M"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "keywordoffer", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that highlighting a search shortcut Top Site enters search mode +// preview. +add_task(async function topSites() { + // Enable search shortcut Top Sites. + await PlacesUtils.history.clear(); + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + + // We previously verified that the first Top Site is a search shortcut. + EventUtils.synthesizeKey("KEY_ArrowDown"); + let searchTopSite = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + await UrlbarTestUtils.exitSearchMode(window); +}); + +// Tests that search mode preview is exited when the view is closed. +add_task(async function closeView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + // We should close search mode when closing the view. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Check search mode isn't re-entered when re-opening the view. + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.assertSearchMode(window, null); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests that search more preview is exited when the user switches tabs. +add_task(async function tabSwitch() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + while (gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + isPreview: true, + entry: "keywordoffer", + }); + + // Open a new tab then switch back to the original tab. + let tab1 = gBrowser.selectedTab; + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await BrowserTestUtils.switchTab(gBrowser, tab1); + + await UrlbarTestUtils.assertSearchMode(window, null); + BrowserTestUtils.removeTab(tab2); +}); + +// Tests that search mode is previewed when the user down arrows through the +// one-offs. +add_task(async function oneOff_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + let resultCount = UrlbarTestUtils.getResultCount(window); + + // Key down through all results. + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Key down again. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Check for the one-off's search mode previews. + while (oneOffs.selectedButton != oneOffs.settingsButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton) + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Check that selecting the search settings button leaves search mode preview. + Assert.equal( + oneOffs.selectedButton, + oneOffs.settingsButton, + "The settings button is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Closing the view should also exit search mode preview. + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that search mode is previewed when the user Alt+down arrows through the +// one-offs. This subtest also highlights a keywordoffer result (the first Top +// Site) before Alt+Arrowing to the one-offs. This checks that the search mode +// previews from keywordoffer results are overwritten by selected one-offs. +add_task(async function oneOff_alt_downArrow() { + // Add some visits to a URL so we have multiple Top Sites. + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + await updateTopSites( + sites => + sites && + sites[0]?.searchTopSite && + sites[1]?.url == "https://example.com/", + true + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + // Key down to the first result and check that it enters search mode preview. + EventUtils.synthesizeKey("KEY_ArrowDown"); + let searchTopSite = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + + // Alt+down. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + // Check for the one-offs' search mode previews. + while (oneOffs.selectedButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + + // Now key down without a modifier. We should move to the second result and + // have no search mode preview. + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "The second result is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Arrow back up to the keywordoffer result and check for search mode preview. + EventUtils.synthesizeKey("KEY_ArrowUp"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: searchTopSite.searchParams.engine, + isPreview: true, + entry: "topsites_urlbar", + }); + + await PlacesUtils.history.clear(); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that search mode is previewed when the user is in full search mode +// and down arrows through the one-offs. +add_task(async function fullSearchMode_oneOff_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + let oneOffButtons = oneOffs.getSelectableButtons(true); + + await UrlbarTestUtils.enterSearchMode(window); + let expectedSearchMode = getExpectedSearchMode(oneOffButtons[0], false); + // Sanity check: we are in the correct search mode. + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Key down through all results. + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + // If the result is a shortcut, it will enter preview mode. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + await UrlbarTestUtils.assertSearchMode( + window, + Object.assign(expectedSearchMode, { + isPreview: !!result.searchParams.keyword, + }) + ); + } + + // Key down again. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + // Check that we show the correct preview as we cycle through the one-offs. + while (oneOffs.selectedButton != oneOffs.settingsButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // We should still be in the same search mode after cycling through all the + // one-offs. + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests that search mode is previewed when the user is in full search mode +// and alt+down arrows through the one-offs. This subtest also checks that +// exiting full search mode while alt+arrowing through the one-offs enters +// search mode preview for subsequent one-offs. +add_task(async function fullSearchMode_oneOff_alt_downArrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + let oneOffButtons = oneOffs.getSelectableButtons(true); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + await UrlbarTestUtils.enterSearchMode(window); + let expectedSearchMode = getExpectedSearchMode(oneOffButtons[0], false); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Key down to the first result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Alt+down. The first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + // Cycle through the first half of the one-offs and verify that search mode + // preview is entered. + Assert.greater( + oneOffButtons.length, + 1, + "Sanity check: We should have at least two one-offs." + ); + for (let i = 1; i < oneOffButtons.length / 2; i++) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + // Now click out of search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + // Now check for the remaining one-offs' search mode previews. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + while (oneOffs.selectedButton) { + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.assertSearchMode(window, null); +}); + +// Tests that the original search mode is preserved when going through some +// one-off buttons and then back down in the results list. +add_task(async function fullSearchMode_oneOff_restore_on_down() { + info("Add a few visits to top sites"); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "https://1.example.com/", + "https://2.example.com/", + "https://3.example.com/", + ]); + } + await updateTopSites(sites => sites?.length > 2, false); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + let oneOffButtons = oneOffs.getSelectableButtons(true); + await TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + let expectedSearchMode = getExpectedSearchMode( + oneOffButtons.find(b => b.source == UrlbarUtils.RESULT_SOURCE.HISTORY), + false + ); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + info("Down to the first result"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + info("Alt+down to the first one-off."); + Assert.greater( + oneOffButtons.length, + 1, + "Sanity check: We should have at least two one-offs." + ); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + info("Go again down through the list of results"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + + // Now do a similar test without initial search mode. + info("Exit search mode."); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + info("Down to the first result"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, null); + info("select a one-off to start preview"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await UrlbarTestUtils.assertSearchMode( + window, + getExpectedSearchMode(oneOffs.selectedButton, true) + ); + info("Go again through the list of results"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, null); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js b/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js new file mode 100644 index 0000000000..ef3fabe636 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js @@ -0,0 +1,332 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search mode and session store. Also tests that search mode is + * duplicated when duplicating tabs, since tab duplication is handled by session + * store. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +// This test takes a long time on the OS X 10.14 machines, so request a longer +// timeout. See bug 1671045. This may also fix a different failure on Linux in +// bug 1671087, but it's not clear. Regardless, a longer timeout won't hurt. +requestLongerTimeout(5); + +const SEARCH_STRING = "test browser_sessionStore.js"; +const URL = "http://example.com/"; + +// A URL in gInitialPages. We test this separately since SessionStore sometimes +// takes different paths for these URLs. +const INITIAL_URL = "about:newtab"; + +// The following tasks make sure non-null search mode is restored. + +add_task(async function initialPageOnRestore() { + await doTest({ + urls: [INITIAL_URL], + searchModeTabIndex: 0, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToInitialPage() { + await doTest({ + urls: [URL, INITIAL_URL], + searchModeTabIndex: 1, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +add_task(async function nonInitialPageOnRestore() { + await doTest({ + urls: [URL], + searchModeTabIndex: 0, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToNonInitialPage() { + await doTest({ + urls: [INITIAL_URL, URL], + searchModeTabIndex: 1, + exitSearchMode: false, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +// The following tasks enter and then exit search mode to make sure that no +// search mode is restored. + +add_task(async function initialPageOnRestore_exit() { + await doTest({ + urls: [INITIAL_URL], + searchModeTabIndex: 0, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToInitialPage_exit() { + await doTest({ + urls: [URL, INITIAL_URL], + searchModeTabIndex: 1, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +add_task(async function nonInitialPageOnRestore_exit() { + await doTest({ + urls: [URL], + searchModeTabIndex: 0, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: false, + }); +}); + +add_task(async function switchToNonInitialPage_exit() { + await doTest({ + urls: [INITIAL_URL, URL], + searchModeTabIndex: 1, + exitSearchMode: true, + switchTabsAfterEnteringSearchMode: true, + }); +}); + +/** + * The main test function. Opens some URLs in a new window, enters search mode + * in one of the tabs, closes the window, restores it, and makes sure that + * search mode is restored properly. + * + * @param {object} options + * Options object + * @param {Array} options.urls + * Array of string URLs to open. + * @param {number} options.searchModeTabIndex + * The index of the tab in which to enter search mode. + * @param {boolean} options.exitSearchMode + * If true, search mode will be immediately exited after entering it. Use + * this to make sure search mode is *not* restored after it's exited. + * @param {boolean} options.switchTabsAfterEnteringSearchMode + * If true, we'll switch to a tab other than the one that search mode was + * entered in before closing the window. `urls` should contain more than one + * URL in this case. + */ +async function doTest({ + urls, + searchModeTabIndex, + exitSearchMode, + switchTabsAfterEnteringSearchMode, +}) { + let searchModeURL = urls[searchModeTabIndex]; + let otherTabIndex = (searchModeTabIndex + 1) % urls.length; + let otherURL = urls[otherTabIndex]; + + await withNewWindow(urls, async win => { + if (win.gBrowser.selectedTab != win.gBrowser.tabs[searchModeTabIndex]) { + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[searchModeTabIndex] + ); + } + + Assert.equal( + win.gBrowser.currentURI.spec, + searchModeURL, + `Sanity check: Tab at index ${searchModeTabIndex} is correct` + ); + Assert.equal( + searchModeURL == INITIAL_URL, + win.gInitialPages.includes(win.gBrowser.currentURI.spec), + `Sanity check: ${searchModeURL} is or is not in gInitialPages as expected` + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: SEARCH_STRING, + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + if (exitSearchMode) { + await UrlbarTestUtils.exitSearchMode(win); + } + + // Make sure session store is updated. + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + if (switchTabsAfterEnteringSearchMode) { + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[otherTabIndex] + ); + } + }); + + let restoredURL = switchTabsAfterEnteringSearchMode + ? otherURL + : searchModeURL; + + let win = await restoreWindow(restoredURL); + + Assert.equal( + win.gBrowser.currentURI.spec, + restoredURL, + "Sanity check: Initially selected tab in restored window is correct" + ); + + if (switchTabsAfterEnteringSearchMode) { + // Switch back to the tab with search mode. + await BrowserTestUtils.switchTab( + win.gBrowser, + win.gBrowser.tabs[searchModeTabIndex] + ); + } + + if (exitSearchMode) { + // If we exited search mode, it should be null. + await new Promise(r => win.setTimeout(r, 500)); + await UrlbarTestUtils.assertSearchMode(win, null); + } else { + // If we didn't exit search mode, it should be restored. + await TestUtils.waitForCondition( + () => win.gURLBar.searchMode, + "Waiting for search mode to be restored" + ); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + win.gURLBar.value, + SEARCH_STRING, + "Search string should be restored" + ); + } + + await BrowserTestUtils.closeWindow(win); +} + +async function openTabMenuFor(tab) { + let tabMenu = tab.ownerDocument.getElementById("tabContextMenu"); + + let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + tab, + { type: "contextmenu" }, + tab.ownerGlobal + ); + await tabMenuShown; + + return tabMenu; +} + +// Tests that search mode is duplicated when duplicating tabs. Note that tab +// duplication is handled by session store. +add_task(async function duplicateTabs() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.selectedTab = tab; + // Enter search mode with a search string in the current tab. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SEARCH_STRING, + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Now duplicate the current tab using the context menu item. + const menu = await openTabMenuFor(gBrowser.selectedTab); + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + gBrowser.currentURI.spec + ); + menu.activateItem(document.getElementById("context_duplicateTab")); + let newTab = await tabPromise; + Assert.equal( + gBrowser.selectedTab, + newTab, + "Sanity check: The duplicated tab is now the selected tab" + ); + + // Wait for search mode, then check it and the input value. + await TestUtils.waitForCondition( + () => gURLBar.searchMode, + "Waiting for search mode to be duplicated/restored" + ); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + SEARCH_STRING, + "Search string should be duplicated/restored" + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(newTab); + gURLBar.handleRevert(); +}); + +/** + * Opens a new browser window with the given URLs, calls a callback, and then + * closes the window. + * + * @param {Array} urls + * Array of string URLs to open. + * @param {Function} callback + * The callback. + */ +async function withNewWindow(urls, callback) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of urls) { + await BrowserTestUtils.openNewForegroundTab({ + url, + gBrowser: win.gBrowser, + waitForLoad: url != "about:newtab", + }); + if (url == "about:newtab") { + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == "about:newtab", + "Waiting for about:newtab" + ); + } + } + BrowserTestUtils.removeTab(win.gBrowser.tabs[0]); + await callback(win); + await BrowserTestUtils.closeWindow(win); +} + +/** + * Uses SessionStore to reopen the last closed window. + * + * @param {string} expectedRestoredURL + * The URL you expect will be restored in the selected browser. + */ +async function restoreWindow(expectedRestoredURL) { + let winPromise = BrowserTestUtils.waitForNewWindow(); + let win = SessionStore.undoCloseWindow(0); + await winPromise; + await TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectedRestoredURL, + "Waiting for restored selected browser to have expected URI" + ); + return win; +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js b/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js new file mode 100644 index 0000000000..46f0a84256 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_setURI.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode remains active or is exited when setURI is called, + * depending on the situation. + */ + +"use strict"; + +// Opens a new tab, does a search, enters search mode, and then manually calls +// setURI. Uses a variety of initial URLs, search strings, and setURI arguments +// in order to hit different branches in setURI. Search mode should remain +// active or be exited as appropriate. +add_task(async function setURI() { + for (let test of [ + // initialURL, searchString, url, expectSearchMode + + ["about:blank", "", null, true], + ["about:blank", "", "about:blank", true], + ["about:blank", "", "http://www.example.com/", true], + + ["about:blank", "about:blank", null, false], + ["about:blank", "about:blank", "about:blank", false], + ["about:blank", "about:blank", "http://www.example.com/", false], + + ["about:blank", "http://www.example.com/", null, true], + ["about:blank", "http://www.example.com/", "about:blank", true], + ["about:blank", "http://www.example.com/", "http://www.example.com/", true], + + ["about:blank", "not a URL", null, true], + ["about:blank", "not a URL", "about:blank", true], + ["about:blank", "not a URL", "http://www.example.com/", true], + + ["http://www.example.com/", "", null, true], + ["http://www.example.com/", "", "about:blank", true], + ["http://www.example.com/", "", "http://www.example.com/", true], + + ["http://www.example.com/", "about:blank", null, false], + ["http://www.example.com/", "about:blank", "about:blank", false], + [ + "http://www.example.com/", + "about:blank", + "http://www.example.com/", + false, + ], + + ["http://www.example.com/", "http://www.example.com/", null, true], + ["http://www.example.com/", "http://www.example.com/", "about:blank", true], + [ + "http://www.example.com/", + "http://www.example.com/", + "http://www.example.com/", + true, + ], + + ["http://www.example.com/", "not a URL", null, true], + ["http://www.example.com/", "not a URL", "about:blank", true], + ["http://www.example.com/", "not a URL", "http://www.example.com/", true], + ]) { + await doSetURITest(...test); + } +}); + +async function doSetURITest(initialURL, searchString, url, expectSearchMode) { + info( + "doSetURITest with args: " + + JSON.stringify({ + initialURL, + searchString, + url, + expectSearchMode, + }) + ); + + await BrowserTestUtils.withNewTab(initialURL, async () => { + if (searchString) { + // Do a search with the search string. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + } else { + // Open top sites. + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + } + + // Enter search mode and close the view. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await UrlbarTestUtils.promisePopupClose(window); + Assert.strictEqual( + gBrowser.selectedBrowser.userTypedValue, + searchString, + `userTypedValue should be ${searchString}` + ); + + // Call setURI. + let uri = url ? Services.io.newURI(url) : null; + gURLBar.setURI(uri); + + await UrlbarTestUtils.assertSearchMode( + window, + !expectSearchMode + ? null + : { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + } + ); + + gURLBar.handleRevert(); + await UrlbarTestUtils.assertSearchMode(window, null); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js b/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js new file mode 100644 index 0000000000..5aa3412580 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js @@ -0,0 +1,579 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests search suggestions in search mode. + */ + +const DEFAULT_ENGINE_NAME = "Test"; +const SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngine.xml"; +const MANY_SUGGESTIONS_ENGINE_NAME = "searchSuggestionEngineMany.xml"; +const MAX_RESULT_COUNT = UrlbarPrefs.get("maxRichResults"); + +let suggestionsEngine; +let expectedFormHistoryResults = []; + +add_setup(async function () { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME, + }); + + await SearchTestUtils.installSearchExtension( + { + name: DEFAULT_ENGINE_NAME, + keyword: "@test", + }, + { setAsDefault: true } + ); + await Services.search.moveEngine(suggestionsEngine, 0); + + async function cleanup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } + await cleanup(); + registerCleanupFunction(cleanup); + + // Add some form history for our test engine. + for (let i = 0; i < MAX_RESULT_COUNT; i++) { + let value = `hello formHistory ${i}`; + await UrlbarTestUtils.formHistory.add([ + { value, source: suggestionsEngine.name }, + ]); + expectedFormHistoryResults.push({ + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + searchParams: { + suggestion: value, + engine: suggestionsEngine.name, + }, + }); + } + + // Add other form history. + await UrlbarTestUtils.formHistory.add([ + { value: "hello formHistory global" }, + { value: "hello formHistory other", source: "other engine" }, + ]); + + registerCleanupFunction(async () => { + await UrlbarTestUtils.formHistory.clear(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.urlbar.suggest.quickactions", false], + ], + }); +}); + +add_task(async function emptySearch() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine and no heuristic. + await checkResults(expectedFormHistoryResults); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function emptySearch_withRestyledHistory() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([ + `http://mochi.test/`, + `http://mochi.test/redirect`, + // Should not be returned because it's a redirect target. + { + uri: `http://mochi.test/target`, + transition: PlacesUtils.history.TRANSITIONS.REDIRECT_TEMPORARY, + referrer: `http://mochi.test/redirect`, + }, + // Can be restyled and dupes form history. + "http://mochi.test:8888/?terms=hello+formHistory+0", + // Can be restyled but does not dupe form history. + "http://mochi.test:8888/?terms=ciao", + ]); + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + searchParams: { + suggestion: "ciao", + engine: suggestionsEngine.name, + }, + }, + ...expectedFormHistoryResults.slice(0, MAX_RESULT_COUNT - 3), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/redirect`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_withRestyledHistory_noSearchHistory() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([ + `http://mochi.test/`, + `http://mochi.test/redirect`, + // Can be restyled but does not dupe form history. + "http://mochi.test:8888/?terms=ciao", + ]); + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.update2.emptySearchBehavior", 2], + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // maxHistoricalSearchSuggestions == 0, so form history should not be + // present. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/redirect`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_behavior() { + // URLs with the same host as the search engine. + await PlacesTestUtils.addVisits([`http://mochi.test/`]); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 0]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // We should still show history for empty searches when not in search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " ", + }); + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query: " ", + engine: DEFAULT_ENGINE_NAME, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 1]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // For the empty search case, we expect to get the form history relative to + // the picked engine, history without redirects, and no heuristic. + await checkResults([...expectedFormHistoryResults]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function emptySearch_local() { + await PlacesTestUtils.addVisits([`http://mochi.test/`]); + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 0]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + Assert.equal(gURLBar.value, "", "Urlbar value should be cleared."); + // Even when emptySearchBehavior is 0, we still show the user's most frecent + // history for an empty search. + await checkResults([ + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/`, + }, + ]); + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function nonEmptySearch() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "hello"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + // We expect to get the heuristic and all the suggestions. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + ...expectedFormHistoryResults.slice(0, MAX_RESULT_COUNT - 3), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}foo`, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}bar`, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +add_task(async function nonEmptySearch_nonMatching() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "ciao"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + // We expect to get the heuristic and the remote suggestions since the local + // ones don't match. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}foo`, + engine: suggestionsEngine.name, + }, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}bar`, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +add_task(async function nonEmptySearch_withHistory() { + let manySuggestionsEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + MANY_SUGGESTIONS_ENGINE_NAME, + }); + // URLs with the same host as the search engine. + let query = "ciao"; + await PlacesTestUtils.addVisits([ + `http://mochi.test/${query}`, + `http://mochi.test/${query}1`, + // Should not be returned because it has a different host, even if it + // matches the host in the path. + `http://example.com/mochi.test/${query}`, + ]); + + function makeSuggestionResult(suffix) { + return { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + suggestion: `${query}${suffix}`, + engine: manySuggestionsEngine.name, + }, + }; + } + + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: manySuggestionsEngine.name, + }); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: manySuggestionsEngine.name, + }, + }, + makeSuggestionResult("foo"), + makeSuggestionResult("bar"), + makeSuggestionResult("1"), + makeSuggestionResult("2"), + makeSuggestionResult("3"), + makeSuggestionResult("4"), + makeSuggestionResult("5"), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}1`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + + info("Test again with history before suggestions"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchSuggestionsFirst", false]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: manySuggestionsEngine.name, + }); + Assert.equal(gURLBar.value, query, "Urlbar value should be set."); + + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: manySuggestionsEngine.name, + }, + }, + makeSuggestionResult("foo"), + makeSuggestionResult("bar"), + makeSuggestionResult("1"), + makeSuggestionResult("2"), + makeSuggestionResult("3"), + makeSuggestionResult("4"), + makeSuggestionResult("5"), + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}1`, + }, + { + heuristic: false, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + url: `http://mochi.test/${query}`, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function nonEmptySearch_url() { + await BrowserTestUtils.withNewTab("about:robots", async function (browser) { + let query = "http://www.example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: query, + }); + await UrlbarTestUtils.enterSearchMode(window); + + // The heuristic result for a search that's a valid URL should be a search + // result, not a URL result. + await checkResults([ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + searchParams: { + query, + engine: suggestionsEngine.name, + }, + }, + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + }); +}); + +async function checkResults(expectedResults) { + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResults.length, + "Check results count." + ); + for (let i = 0; i < expectedResults.length; ++i) { + info(`Checking result at index ${i}`); + let expected = expectedResults[i]; + let actual = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + + // Check each property defined in the expected result against the property + // in the actual result. + for (let key of Object.keys(expected)) { + // For searchParams, remove undefined properties in the actual result so + // that the expected result doesn't need to include them. + if (key == "searchParams") { + let actualSearchParams = actual.searchParams; + for (let spKey of Object.keys(actualSearchParams)) { + if (actualSearchParams[spKey] === undefined) { + delete actualSearchParams[spKey]; + } + } + } + Assert.deepEqual( + actual[key], + expected[key], + `${key} should match at result index ${i}.` + ); + } + } +} diff --git a/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js b/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js new file mode 100644 index 0000000000..c4f541c9cd --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js @@ -0,0 +1,317 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search mode is stored per tab and restored when switching tabs. + */ + +"use strict"; + +// Enters search mode using the one-off buttons. +add_task(async function switchTabs() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Open three tabs. We'll enter search mode in tabs 0 and 2. + let tabs = []; + for (let i = 0; i < 3; i++) { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "http://example.com/" + i, + }); + tabs.push(tab); + } + + // Switch to tab 0. + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + // Do a search and enter search mode. Pass fireInputEvent so that + // userTypedValue is set and restored when we switch back to this tab. This + // isn't really necessary but it simulates the user's typing, and it also + // means that we'll start a search when we switch back to this tab. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + // Switch to tab 1. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[1]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Switch back to tab 0. We should do a search (for "test") and re-enter + // search mode. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch to tab 2. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Do another search (in tab 2) and enter search mode. Use a different source + // from tab 0 just to use something different. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test tab 2", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + + // Switch back to tab 0. We should do a search and still be in search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch to tab 1. Search mode should be exited. + await BrowserTestUtils.switchTab(gBrowser, tabs[1]); + await UrlbarTestUtils.assertSearchMode(window, null); + + // Switch back to tab 2. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // Switch to tab 0. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 2. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 0. We should do a search and re-enter search mode. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + + // Switch back to tab 2. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[2]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test tab 2", + "Value should remain the search string after switching back" + ); + + // Switch back to tab 0. We should do a search but search mode should be + // inactive. + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "test", + "Value should remain the search string after switching back" + ); + + await UrlbarTestUtils.promisePopupClose(window); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +// Start loading a SERP from search mode then immediately switch to a new tab so +// the SERP finishes loading in the background. Switch back to the SERP tab and +// observe that we don't re-enter search mode despite having an entry for that +// tab in UrlbarInput._searchModesByBrowser. See bug 1675926. +// +// This subtest intermittently does not test bug 1675926 (NB: this does not mean +// it is an intermittent failure). The false-positive occurs if the SERP page +// finishes loading before we switch tabs. We include this subtest so we have +// one covering real-world behaviour. A subtest that is guaranteed to test this +// behaviour that does not simulate real world behaviour is included below. +add_task(async function slow_load() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + const engineName = "Test"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: engineName, + }, + { skipUnload: true } + ); + + const originalTab = gBrowser.selectedTab; + const newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + await UrlbarTestUtils.enterSearchMode(window, { engineName }); + + const loadPromise = BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + // Select the search mode heuristic to load the example.com SERP. + EventUtils.synthesizeKey("KEY_Enter"); + // Switch away from the tab before we let it load. + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await loadPromise; + + // Switch back to the search mode tab and confirm we don't restore search + // mode. + await BrowserTestUtils.switchTab(gBrowser, newTab); + await UrlbarTestUtils.assertSearchMode(window, null); + + BrowserTestUtils.removeTab(newTab); + await SpecialPowers.popPrefEnv(); + await extension.unload(); +}); + +// Tests the same behaviour as slow_load, but in a more reliable way using +// non-real-world behaviour. +add_task(async function slow_load_guaranteed() { + const engineName = "Test"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: engineName, + }, + { skipUnload: true } + ); + + const backgroundTab = BrowserTestUtils.addTab(gBrowser); + + // Simulate a tab that was in search mode, loaded a SERP, then was switched + // away from before setURI was called. + backgroundTab.ownerGlobal.gURLBar.searchMode = { engineName }; + let loadPromise = BrowserTestUtils.browserLoaded(backgroundTab.linkedBrowser); + BrowserTestUtils.loadURIString( + backgroundTab.linkedBrowser, + "http://example.com/?search=test" + ); + await loadPromise; + + // Switch to the background mode tab and confirm we don't restore search mode. + await BrowserTestUtils.switchTab(gBrowser, backgroundTab); + await UrlbarTestUtils.assertSearchMode(window, null); + + BrowserTestUtils.removeTab(backgroundTab); + await extension.unload(); +}); + +// Enters search mode by typing a restriction char with no search string. +// Search mode and the search string should be restored after switching back to +// the tab. +add_task(async function userTypedValue_empty() { + await doUserTypedValueTest(""); +}); + +// Enters search mode by typing a restriction char followed by a search string. +// Search mode and the search string should be restored after switching back to +// the tab. +add_task(async function userTypedValue_nonEmpty() { + await doUserTypedValueTest("foo bar"); +}); + +/** + * Enters search mode by typing a restriction char followed by a search string, + * opens a new tab and immediately closes it so we switch back to the search + * mode tab, and checks the search mode state and input value. + * + * @param {string} searchString + * The search string to enter search mode with. + */ +async function doUserTypedValueTest(searchString) { + let value = `${UrlbarTokenizer.RESTRICT.BOOKMARK} ${searchString}`; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + fireInputEvent: true, + }); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + searchString, + "Sanity check: Value is the search string" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + BrowserTestUtils.removeTab(tab); + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "typed", + }); + Assert.equal( + gURLBar.value, + searchString, + "Value should remain the search string after switching back" + ); + + gURLBar.handleRevert(); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchSettings.js b/browser/components/urlbar/tests/browser/browser_searchSettings.js new file mode 100644 index 0000000000..2cded38c99 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSettings.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "a", + }); + + // Since the current tab is blank the preferences pane will load there + let loaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + let button = document.getElementById("urlbar-anon-search-settings"); + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + await loaded; + + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:preferences#search", + "Should have loaded the right page" + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js b/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js new file mode 100644 index 0000000000..36a065d58e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js @@ -0,0 +1,372 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +let gDNSResolved = false; +add_setup(async function () { + registerCleanupFunction(function () { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.localhost"); + }); +}); + +function promiseNotification(aBrowser, value, expected, input) { + return new Promise(resolve => { + let notificationBox = aBrowser.getNotificationBox(aBrowser.selectedBrowser); + if (expected) { + info("Waiting for " + value + " notification"); + resolve( + BrowserTestUtils.waitForNotificationInNotificationBox( + notificationBox, + value + ) + ); + } else { + setTimeout(() => { + is( + notificationBox.getNotificationWithValue(value), + null, + `We are expecting to not get a notification for ${input}` + ); + resolve(); + }, 1000); + } + }); +} + +async function runURLBarSearchTest({ + valueToOpen, + enterSearchMode, + expectSearch, + expectNotification, + expectDNSResolve, + aWindow = window, +}) { + gDNSResolved = false; + // Test both directly setting a value and pressing enter, or setting the + // value through input events, like the user would do. + const setValueFns = [ + value => { + aWindow.gURLBar.value = value; + if (enterSearchMode) { + // Ensure to open the panel. + UrlbarTestUtils.fireInputEvent(aWindow); + } + }, + value => { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: aWindow, + value, + }); + }, + ]; + + for (let i = 0; i < setValueFns.length; ++i) { + await setValueFns[i](valueToOpen); + let topic = "uri-fixup-check-dns"; + let observer = (aSubject, aTopicInner, aData) => { + if (aTopicInner == topic) { + gDNSResolved = true; + } + }; + Services.obs.addObserver(observer, topic); + + if (enterSearchMode) { + if (!expectSearch) { + throw new Error("Must execute a search in search mode"); + } + await UrlbarTestUtils.enterSearchMode(aWindow); + } + + let expectedURI; + if (!expectSearch) { + expectedURI = "http://" + valueToOpen + "/"; + } else { + expectedURI = (await Services.search.getDefault()).getSubmission( + valueToOpen, + null, + "keyword" + ).uri.spec; + } + aWindow.gURLBar.focus(); + let docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURI, + aWindow.gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("VK_RETURN", {}, aWindow); + + if (!enterSearchMode) { + await promiseNotification( + aWindow.gBrowser, + "keyword-uri-fixup", + expectNotification, + valueToOpen + ); + } + await docLoadPromise; + + if (expectNotification) { + let notificationBox = aWindow.gBrowser.getNotificationBox( + aWindow.gBrowser.selectedBrowser + ); + let notification = + notificationBox.getNotificationWithValue("keyword-uri-fixup"); + // Confirm the notification only on the last loop. + if (i == setValueFns.length - 1) { + docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + "http://" + valueToOpen + "/", + aWindow.gBrowser.selectedBrowser + ); + notification.buttonContainer.querySelector("button").click(); + await docLoadPromise; + } else { + notificationBox.currentNotification.close(); + } + } + + Services.obs.removeObserver(observer, topic); + Assert.equal( + gDNSResolved, + expectDNSResolve, + `Should${expectDNSResolve ? "" : " not"} DNS resolve "${valueToOpen}"` + ); + } +} + +add_task(async function test_navigate_full_domain() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "www.singlewordtest.org", + expectSearch: false, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_decimal_ip() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "1234", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_decimal_ip_with_path() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "1234/12", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_large_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "123456789012345", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_small_hex_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "0x1f00ffff", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_navigate_large_hex_number() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "0x7f0000017f000001", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, // Possible IP in numeric format. + }); + gBrowser.removeTab(tab); +}); + +function get_test_function_for_localhost_with_hostname( + hostName, + isPrivate = false +) { + return async function test_navigate_single_host() { + info(`Test ${hostName}${isPrivate ? " in Private Browsing mode" : ""}`); + const pref = "browser.fixup.domainwhitelist.localhost"; + let win; + if (isPrivate) { + let promiseWin = BrowserTestUtils.waitForNewWindow(); + win = OpenBrowserWindow({ private: true }); + await promiseWin; + await SimpleTest.promiseFocus(win); + } else { + win = window; + } + + // Remove the domain from the whitelist + Services.prefs.setBoolPref(pref, false); + + // The notification should not appear because the default value of + // browser.urlbar.dnsResolveSingleWordsAfterSearch is 0 + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + aWindow: win, + }) + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.dnsResolveSingleWordsAfterSearch", 1]], + }); + + // The notification should appear, unless we are in private browsing mode. + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: true, + expectNotification: true, + expectDNSResolve: true, + aWindow: win, + }) + ); + + // check pref value + let prefValue = Services.prefs.getBoolPref(pref); + is(prefValue, !isPrivate, "Pref should have the correct state."); + + // Now try again with the pref set. + // In a private window, the notification should appear again. + await BrowserTestUtils.withNewTab( + { + gBrowser: win.gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: hostName, + expectSearch: isPrivate, + expectNotification: isPrivate, + expectDNSResolve: isPrivate, + aWindow: win, + }) + ); + + if (isPrivate) { + info("Waiting for private window to close"); + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); + } + + await SpecialPowers.popPrefEnv(); + }; +} + +add_task(get_test_function_for_localhost_with_hostname("localhost")); +add_task(get_test_function_for_localhost_with_hostname("localhost.")); +add_task(get_test_function_for_localhost_with_hostname("localhost", true)); + +add_task(async function test_dnsResolveSingleWordsAfterSearch() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.dnsResolveSingleWordsAfterSearch", 0], + ["browser.fixup.domainwhitelist.localhost", false], + ], + }); + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + browser => + runURLBarSearchTest({ + valueToOpen: "localhost", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }) + ); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_navigate_invalid_url() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + valueToOpen: "mozilla is awesome", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_search_mode() { + info("When in search mode we should never query the DNS"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await runURLBarSearchTest({ + enterSearchMode: true, + valueToOpen: "mozilla", + expectSearch: true, + expectNotification: false, + expectDNSResolve: false, + }); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_searchSuggestions.js b/browser/components/urlbar/tests/browser/browser_searchSuggestions.js new file mode 100644 index 0000000000..8a226a3c4c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSuggestions.js @@ -0,0 +1,341 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests checks that search suggestions can be acted upon correctly + * e.g. selection with modifiers, copying text. + */ + +"use strict"; + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +const MAX_CHARS_PREF = "browser.urlbar.maxCharsForSearchSuggestions"; + +// Must run first. +add_task(async function prepare() { + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + await UrlbarTestUtils.formHistory.clear(); + registerCleanupFunction(async function () { + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + + // Clicking suggestions causes visits to search results pages, so clear that + // history now. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function clickSuggestion() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion, engineName] = await getFirstSuggestion(); + Assert.equal( + engineName, + "browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Expected suggestion engine" + ); + + let uri = (await Services.search.getDefault()).getSubmission(suggestion).uri; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri.spec + ); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, idx); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + await loadPromise; + + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: engineName }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foofoo"], + "Should find form history after adding it" + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +}); + +async function testPressEnterOnSuggestion( + expectedUrl = null, + keyModifiers = {} +) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion, engineName] = await getFirstSuggestion(); + Assert.equal( + engineName, + "browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Expected suggestion engine" + ); + + let hasExpectedUrl = !!expectedUrl; + if (!expectedUrl) { + expectedUrl = (await Services.search.getDefault()).getSubmission(suggestion) + .uri.spec; + } + + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedUrl, + gBrowser.selectedBrowser + ); + + let promiseFormHistory; + if (!hasExpectedUrl) { + promiseFormHistory = UrlbarTestUtils.formHistory.promiseChanged("add"); + } + + for (let i = 0; i < idx; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + EventUtils.synthesizeKey("KEY_Enter", keyModifiers); + + await promiseLoad; + + if (!hasExpectedUrl) { + await promiseFormHistory; + let formHistory = ( + await UrlbarTestUtils.formHistory.search({ source: engineName }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foofoo"], + "Should find form history after adding it" + ); + } + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +} + +add_task(async function plainEnterOnSuggestion() { + await testPressEnterOnSuggestion(); +}); + +add_task(async function ctrlEnterOnSuggestion() { + await testPressEnterOnSuggestion("https://www.foofoo.com/", { + ctrlKey: true, + }); +}); + +add_task(async function copySuggestionText() { + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let [idx, suggestion] = await getFirstSuggestion(); + for (let i = 0; i < idx; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + gURLBar.select(); + await SimpleTest.promiseClipboardChange(suggestion, () => { + goDoCommand("cmd_copy"); + }); +}); + +add_task(async function typeMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string as long as maxChars and type it. + let value = ""; + for (let i = 0; i < maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + + // Suggestions should be fetched since we allow them when typing, and the + // value so far isn't longer than maxChars anyway. + await assertSuggestions([value + "foo", value + "bar"]); + + // Now type some additional chars. Suggestions should still be fetched since + // we allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function pasteMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string as long as maxChars and paste it. + let value = ""; + for (let i = 0; i < maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await selectAndPaste(value); + + // Suggestions should be fetched since the pasted string is not longer than + // maxChars. + await assertSuggestions([value + "foo", value + "bar"]); + + // Now type some additional chars. Suggestions should still be fetched since + // we allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function pasteMoreThanMaxChars() { + gURLBar.focus(); + + let maxChars = 10; + await SpecialPowers.pushPrefEnv({ + set: [[MAX_CHARS_PREF, maxChars]], + }); + + // Make a string longer than maxChars and paste it. + let value = ""; + for (let i = 0; i < 2 * maxChars; i++) { + value += String.fromCharCode("a".charCodeAt(0) + i); + } + await selectAndPaste(value); + + // Suggestions should not be fetched since the value was pasted and it was + // longer than maxChars. + await assertSuggestions([]); + + // Now type some additional chars. Suggestions should now be fetched since we + // allow them when typing. + for (let i = 0; i < 3; i++) { + let char = String.fromCharCode("z".charCodeAt(0) - i); + value += char; + EventUtils.synthesizeKey(char); + await assertSuggestions([value + "foo", value + "bar"]); + } + + // Paste again. The string is longer than maxChars, so suggestions should not + // be fetched. + await selectAndPaste(value); + await assertSuggestions([]); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function heuristicAddsFormHistory() { + await UrlbarTestUtils.formHistory.clear(); + let formHistory = (await UrlbarTestUtils.formHistory.search()).map( + entry => entry.value + ); + Assert.deepEqual(formHistory, [], "Form history should be empty initially"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.searchParams.query, "foo"); + + let uri = (await Services.search.getDefault()).getSubmission("foo").uri; + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + uri.spec + ); + let formHistoryPromise = UrlbarTestUtils.formHistory.promiseChanged("add"); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + await loadPromise; + + await formHistoryPromise; + formHistory = ( + await UrlbarTestUtils.formHistory.search({ + source: result.searchParams.engine, + }) + ).map(entry => entry.value); + Assert.deepEqual( + formHistory, + ["foo"], + "Should find form history after adding it" + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); +}); + +async function getFirstSuggestion() { + let results = await getSuggestionResults(); + if (!results.length) { + return [-1, null, null]; + } + let result = results[0]; + return [ + result.index, + result.searchParams.suggestion, + result.searchParams.engine, + ]; +} + +async function getSuggestionResults() { + await UrlbarTestUtils.promiseSearchComplete(window); + + let results = []; + let matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + result.index = i; + results.push(result); + } + } + return results; +} + +async function assertSuggestions(expectedSuggestions) { + let results = await getSuggestionResults(); + let actualSuggestions = results.map(r => r.searchParams.suggestion); + Assert.deepEqual( + actualSuggestions, + expectedSuggestions, + "Expected suggestions" + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_searchTelemetry.js b/browser/components/urlbar/tests/browser/browser_searchTelemetry.js new file mode 100644 index 0000000000..61ddff4c2d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchTelemetry.js @@ -0,0 +1,220 @@ +"use strict"; + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const MAX_FORM_HISTORY_PREF = "browser.urlbar.maxHistoricalSearchSuggestions"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// Must run first. +add_task(async function prepare() { + await SpecialPowers.pushPrefEnv({ + set: [ + [SUGGEST_URLBAR_PREF, true], + [MAX_FORM_HISTORY_PREF, 2], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + registerCleanupFunction(async function () { + // Clicking urlbar results causes visits to their associated pages, so clear + // that history now. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + // Move the mouse away from the urlbar one-offs so that a one-off engine is + // not inadvertently selected. + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: window.document.documentElement, + offsetX: 0, + offsetY: 0, + }); +}); + +add_task(async function heuristicResultMouse() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "heuristicResult", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should be of type search" + ); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function heuristicResultKeyboard() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "heuristicResult", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should be of type search" + ); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function searchSuggestionMouse() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + idx + ); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function searchSuggestionKeyboard() { + await compareCounts(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function formHistoryMouse() { + await compareCounts(async function () { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let index = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(index, 0, "there should be a first suggestion"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + index + ); + EventUtils.synthesizeMouseAtCenter(element, {}); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +add_task(async function formHistoryKeyboard() { + await compareCounts(async function () { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let index = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(index, 0, "there should be a first suggestion"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.HISTORY); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (index--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + }); +}); + +/** + * This does three things: gets current telemetry/FHR counts, calls + * clickCallback, gets telemetry/FHR counts again to compare them to the old + * counts. + * + * @param {Function} clickCallback Use this to open the urlbar popup and choose + * and click a result. + */ +async function compareCounts(clickCallback) { + // Search events triggered by clicks (not the Return key in the urlbar) are + // recorded in three places: + // * Telemetry histogram named "SEARCH_COUNTS" + // * FHR + + let engine = await Services.search.getDefault(); + + let histogramKey = `other-${engine.name}.urlbar`; + let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS"); + histogram.clear(); + + gURLBar.focus(); + await clickCallback(); + + TelemetryTestUtils.assertKeyedHistogramSum(histogram, histogramKey, 1); +} + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} diff --git a/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js b/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js new file mode 100644 index 0000000000..e42fcc9f7f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_search_bookmarks_from_bookmarks_menu.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function searchBookmarksFromBooksmarksMenu() { + // Add Button to toolbar + CustomizableUI.addWidgetToArea( + "bookmarks-menu-button", + CustomizableUI.AREA_NAVBAR, + 0 + ); + let bookmarksMenuButton = document.getElementById("bookmarks-menu-button"); + ok(bookmarksMenuButton, "Bookmarks Menu Button added"); + + // Open Bookmarks-Menu-Popup + let bookmarksMenuPopup = document.getElementById("BMB_bookmarksPopup"); + let PopupShownPromise = BrowserTestUtils.waitForEvent( + bookmarksMenuPopup, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(bookmarksMenuButton, { + type: "mousedown", + }); + await PopupShownPromise; + ok(true, "Bookmarks Menu Popup shown"); + + // Click on 'Search Bookmarks' + let searchBookmarksButton = document.getElementById("BMB_searchBookmarks"); + ok( + BrowserTestUtils.is_visible( + searchBookmarksButton, + "'Search Bookmarks Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchBookmarksButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "Addressbar in correct mode." + ); + + resetCUIAndReinitUrlbarInput(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js b/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js new file mode 100644 index 0000000000..b901a87736 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_search_history_from_history_panel.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_task(async function searchHistoryFromHistoryPanel() { + // Add Button to toolbar + CustomizableUI.addWidgetToArea( + "history-panelmenu", + CustomizableUI.AREA_NAVBAR, + 0 + ); + registerCleanupFunction(() => { + resetCUIAndReinitUrlbarInput(); + }); + + let historyButton = document.getElementById("history-panelmenu"); + ok(historyButton, "History button appears in Panel Menu"); + + historyButton.click(); + + let historyPanel = document.getElementById("PanelUI-history"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); + await promise; + ok(historyPanel.getAttribute("visible"), "History Panel is in view"); + + // Click on 'Search Bookmarks' + let searchHistoryButton = document.getElementById("appMenuSearchHistory"); + ok( + BrowserTestUtils.is_visible( + searchHistoryButton, + "'Search History Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchHistoryButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Addressbar in correct mode." + ); + gURLBar.searchMode = null; + gURLBar.blur(); +}); + +add_task(async function searchHistoryFromAppMenuHistoryButton() { + // Open main menu and click on 'History' button + await gCUITestUtils.openMainMenu(); + let historyButton = document.getElementById("appMenu-history-button"); + historyButton.click(); + + let historyPanel = document.getElementById("PanelUI-history"); + let promise = BrowserTestUtils.waitForEvent(historyPanel, "ViewShown"); + await promise; + ok(historyPanel.getAttribute("visible"), "History Panel is in view"); + + // Click on 'Search Bookmarks' + let searchHistoryButton = document.getElementById("appMenuSearchHistory"); + ok( + BrowserTestUtils.is_visible( + searchHistoryButton, + "'Search History Button' is visible." + ) + ); + EventUtils.synthesizeMouseAtCenter(searchHistoryButton, {}); + + await new Promise(resolve => { + window.gURLBar.controller.addQueryListener({ + onViewOpen() { + window.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + + // Verify URLBar is in search mode with correct restriction + is( + gURLBar.searchMode?.source, + UrlbarUtils.RESULT_SOURCE.HISTORY, + "Addressbar in correct mode." + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_selectStaleResults.js b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js new file mode 100644 index 0000000000..16366f5b33 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js @@ -0,0 +1,311 @@ +/* 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/. */ + +// This test makes sure that arrowing down and up through the view's results +// works correctly with regard to stale results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + // Increase the timeout of the remove-stale-rows timer so that it doesn't + // interfere with the tests. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + UrlbarView.removeStaleRowsTimeout = 1000; + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + }); +}); + +// This tests the case where queryContext.results.length < the number of rows in +// the view, i.e., the view contains stale rows. +add_task(async function viewContainsStaleRows() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + let maxResults = UrlbarPrefs.get("maxRichResults"); + let halfResults = Math.floor(maxResults / 2); + + // Add enough visits to pages with "xx" in the title to fill up half the view. + for (let i = 0; i < halfResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://mochi.test:8888/" + i, + title: "xx" + i, + }); + } + + // Add enough visits to pages with "x" in the title to fill up the entire + // view. + for (let i = 0; i < maxResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://example.com/" + i, + title: "x" + i, + }); + } + + gURLBar.focus(); + + // Search for "x" and wait for the search to finish. All the "x" results + // added above should be in the view. (Actually one fewer will be in the + // view due to the heuristic result, but that's not important.) + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "x", + fireInputEvent: true, + }); + + // Below we'll do a search for "xx". Get the row that will show the last + // result in that search. + let row = UrlbarTestUtils.getRowAt(window, halfResults); + + // Add a mutation listener on that row. Wait for its "stale" attribute to be + // removed. + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + for (let mut of mutations) { + if (mut.attributeName == "stale" && !row.hasAttribute("stale")) { + observer.disconnect(); + resolve(); + break; + } + } + }); + observer.observe(row, { attributes: true }); + }); + + // Type another "x" so that we search for "xx", but don't wait for the search + // to finish. Instead, wait for the row's stale attribute to be removed. + EventUtils.synthesizeKey("x"); + info("Waiting for 'stale' attribute to be removed... "); + await mutationPromise; + + // Now arrow down. The search, which is still ongoing, will now stop and the + // view won't be updated anymore. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Wait for the search to stop. + info("Waiting for the search to stop... "); + await gURLBar.lastQueryContextPromise; + + // The query context for the last search ("xx") should contain only + // halfResults + 1 results (+ 1 for the heuristic). + Assert.ok(gURLBar.controller._lastQueryContextWrapper); + let { queryContext } = gURLBar.controller._lastQueryContextWrapper; + Assert.ok(queryContext); + Assert.equal(queryContext.results.length, halfResults + 1); + + // But there should be maxResults visible rows in the view. + let items = Array.from( + UrlbarTestUtils.getResultsContainer(window).children + ).filter(r => BrowserTestUtils.is_visible(r)); + Assert.equal(items.length, maxResults); + + // Arrow down through all the results. After arrowing down from the last "xx" + // result, the stale "x" results should be selected. We should *not* enter + // the one-off search buttons at that point. + for (let i = 1; i < maxResults; i++) { + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.element.row.result.rowIndex, i); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Now the first one-off should be selected. + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), -1); + Assert.equal(gURLBar.view.oneOffSearchButtons.selectedButtonIndex, 0); + + // Arrow back up through all the results. + for (let i = maxResults - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// This tests the case where, before the search finishes, stale results have +// been removed and replaced with non-stale results. +add_task(async function staleReplacedWithFresh() { + // For this test, we need one set of results that's added quickly and another + // set that's added after a delay. We do an initial search and wait for both + // sets to be added. Then we do another search, but this time only wait for + // the fast results to be added, and then we arrow down to stop the search + // before the delayed results are added. The order in which things should + // happen after the second search goes like this: + // + // (1) second search + // (2) fast results are added + // (3) remove-stale-rows timer fires and removes stale rows (the rows from + // the delayed set of results from the first search) + // (4) we arrow down to stop the search + // + // We use history for the fast results and a slow search engine for the + // delayed results. + // + // NB: If this test ends up failing, it may be because the remove-stale-rows + // timer fires before the history results are added. i.e., steps 2 and 3 + // above happen out of order. If that happens, try increasing + // UrlbarView.removeStaleRowsTimeout above. + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Enable search suggestions, and add an engine that returns suggestions on a + // delay. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngineSlow.xml", + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.moveEngine(engine, 0); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let maxResults = UrlbarPrefs.get("maxRichResults"); + + // Add enough visits to pages with "test" in the title to fill up the entire + // view. + for (let i = 0; i < maxResults; i++) { + await PlacesTestUtils.addVisits({ + uri: "http://example.com/" + i, + title: "test" + i, + }); + } + + gURLBar.focus(); + + // Search for "tes" and wait for the search to finish. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "tes", + fireInputEvent: true, + }); + + // Sanity check the results. They should be: + // + // tes -- Search with searchSuggestionEngineSlow [heuristic] + // tesfoo [search suggestion] + // tesbar [search suggestion] + // test9 [history] + // test8 [history] + // test7 [history] + // test6 [history] + // test5 [history] + // test4 [history] + // test3 [history] + let count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, maxResults); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.ok(result.searchParams); + Assert.equal(result.searchParams.suggestion, "tesfoo"); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.ok(result.searchParams); + Assert.equal(result.searchParams.suggestion, "tesbar"); + for (let i = 3; i < maxResults; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal(result.title, "test" + (maxResults - i + 2)); + } + + // Below we'll do a search for "test" *but* not wait for the two search + // suggestion results to be added. We'll only wait for the history results to + // be added. To determine when the history results are added, use a mutation + // listener on the node containing the rows, and wait until the title of the + // next-to-last row is "test2". At that point, the results should be: + // + // test -- Search with searchSuggestionEngineSlow + // test9 + // test8 + // test7 + // test6 + // test5 + // test4 + // test3 + // test2 + // test1 + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + let row = UrlbarTestUtils.getRowAt(window, maxResults - 2); + if (row && row._elements.get("title").textContent == "test2") { + observer.disconnect(); + resolve(); + } + }); + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + subtree: true, + characterData: true, + childList: true, + attributes: true, + }); + }); + + // Now type a "t" so that we search for "test", but only wait for history + // results to be added, as described above. + EventUtils.synthesizeKey("t"); + info("Waiting for the 'test2' row... "); + await mutationPromise; + + // Now arrow down. The search, which is still ongoing, will now stop and the + // view won't be updated anymore. + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Wait for the search to stop. + info("Waiting for the search to stop... "); + await gURLBar.lastQueryContextPromise; + + // Sanity check the results. They should be as described above. + count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, maxResults); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.heuristic); + Assert.equal(result.element.row.result.rowIndex, 0); + for (let i = 1; i < maxResults; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.URL); + Assert.equal(result.title, "test" + (maxResults - i)); + Assert.equal(result.element.row.result.rowIndex, i); + } + + // Arrow down through all the results. After arrowing down from "test3", we + // should continue on to "test2". We should *not* enter the one-off search + // buttons at that point. + for (let i = 1; i < maxResults; i++) { + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // Now the first one-off should be selected. + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), -1); + Assert.equal(gURLBar.view.oneOffSearchButtons.selectedButtonIndex, 0); + + // Arrow back up through all the results. + for (let i = maxResults - 1; i >= 0; i--) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js b/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js new file mode 100644 index 0000000000..89ba179833 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js @@ -0,0 +1,200 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that the up/down and page-up/down properly adjust the +// selection. See also browser_caret_navigation.js and +// browser_urlbar_tabKeyBehavior.js. + +"use strict"; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); + +add_setup(async function () { + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function downKey() { + for (const ctrlKey of [false, true]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + for (let i = 1; i < MAX_RESULTS; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowDown", { ctrlKey }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected again" + ); + } +}); + +add_task(async function upKey() { + for (const ctrlKey of [false, true]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - 1, + "The last result should be selected" + ); + for (let i = 1; i < MAX_RESULTS; i++) { + EventUtils.synthesizeKey("KEY_ArrowUp", { ctrlKey }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - i - 1 + ); + } + } +}); + +add_task(async function pageDownKey() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + let pageCount = Math.ceil((MAX_RESULTS - 1) / UrlbarUtils.PAGE_UP_DOWN_DELTA); + for (let i = 0; i < pageCount; i++) { + EventUtils.synthesizeKey("KEY_PageDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + Math.min((i + 1) * UrlbarUtils.PAGE_UP_DOWN_DELTA, MAX_RESULTS - 1) + ); + } + EventUtils.synthesizeKey("KEY_PageDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "Page down at end should wrap around to first result" + ); +}); + +add_task(async function pageUpKey() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected initially" + ); + EventUtils.synthesizeKey("KEY_PageUp"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + MAX_RESULTS - 1, + "Page up at start should wrap around to last result" + ); + let pageCount = Math.ceil((MAX_RESULTS - 1) / UrlbarUtils.PAGE_UP_DOWN_DELTA); + for (let i = 0; i < pageCount; i++) { + EventUtils.synthesizeKey("KEY_PageUp"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + Math.max(MAX_RESULTS - 1 - (i + 1) * UrlbarUtils.PAGE_UP_DOWN_DELTA, 0) + ); + } +}); + +add_task(async function pageDownKeyShowsView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_PageDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window)); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); +}); + +add_task(async function pageUpKeyShowsView() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("KEY_PageUp"); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.ok(UrlbarTestUtils.isPopupOpen(window)); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); +}); + +add_task(async function pageDownKeyWithCtrlKey() { + const previousTab = gBrowser.selectedTab; + const currentTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey: true }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gBrowser.selectedTab, previousTab); + BrowserTestUtils.removeTab(currentTab); +}); + +add_task(async function pageUpKeyWithCtrlKey() { + const previousTab = gBrowser.selectedTab; + const currentTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey: true }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gBrowser.selectedTab, previousTab); + BrowserTestUtils.removeTab(currentTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js new file mode 100644 index 0000000000..8cdc0e746b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the 'Search in a Private Window' result of the address bar. +// Tests here don't have a different private engine, for that see +// browser_separatePrivateDefault_differentPrivateEngine.js + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault.urlbarResult.enabled", true], + ["browser.search.separatePrivateDefault", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + + // Add some history for the empty panel. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + setAsDefaultPrivate: true, + }); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "POSTSearchEngine.xml", + }); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + keyword: "alias", + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +async function AssertNoPrivateResult(win) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 0, "Sanity check result count"); + for (let i = 0; i < count; ++i) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !result.searchParams.inPrivateWindow, + "Check this result is not a 'Search in a Private Window' one" + ); + } +} + +async function AssertPrivateResult(win, engine, isPrivateEngine) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 1, "Sanity check result count"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Check result type" + ); + Assert.ok(result.searchParams.inPrivateWindow, "Check inPrivateWindow"); + Assert.equal( + result.searchParams.isPrivateEngine, + isPrivateEngine, + "Check isPrivateEngine" + ); + Assert.equal( + result.searchParams.engine, + engine.name, + "Check the search engine" + ); + return result; +} + +add_task(async function test_nonsearch() { + info( + "Test that 'Search in a Private Window' does not appear with non-search results" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + }); + await AssertNoPrivateResult(window); +}); + +add_task(async function test_search() { + info( + "Test that 'Search in a Private Window' appears with only search results" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, await Services.search.getDefault(), false); +}); + +add_task(async function test_search_urlbar_result_disabled() { + info("Test that 'Search in a Private Window' does not appear when disabled"); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.urlbarResult.enabled", false], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertNoPrivateResult(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_search_disabled_suggestions() { + info( + "Test that 'Search in a Private Window' appears if suggestions are disabled" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, await Services.search.getDefault(), false); + await SpecialPowers.popPrefEnv(); +}); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_keyboard() { +// info( +// "Test that 'Search in a Private Window' with keyboard opens the selected one-off engine if there's no private engine" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult(window, await Services.search.getDefault(), false); +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: +// "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs", +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// EventUtils.synthesizeKey("VK_RETURN"); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// await SpecialPowers.popPrefEnv(); +// }); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_mouse() { +// info( +// "Test that 'Search in a Private Window' with mouse opens the selected one-off engine if there's no private engine" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult(window, await Services.search.getDefault(), false); +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: +// "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/print_postdata.sjs", +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// // Click on the result. +// let element = UrlbarTestUtils.getSelectedRow(window); +// EventUtils.synthesizeMouseAtCenter(element, {}); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// await SpecialPowers.popPrefEnv(); +// }); diff --git a/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js new file mode 100644 index 0000000000..58a60d68a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js @@ -0,0 +1,354 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the 'Search in a Private Window' result of the address bar. + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +let gAliasEngine; +let gPrivateEngine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault.urlbarResult.enabled", true], + ["browser.search.separatePrivateDefault", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + + // Add some history for the empty panel. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + gPrivateEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine2.xml", + setAsDefaultPrivate: true, + }); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "POSTSearchEngine.xml", + }); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + keyword: "alias", + }); + gAliasEngine = Services.search.getEngineByName("MozSearch"); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +async function AssertNoPrivateResult(win) { + let count = await UrlbarTestUtils.getResultCount(win); + for (let i = 0; i < count; ++i) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !result.searchParams.inPrivateWindow, + "Check this result is not a 'Search in a Private Window' one" + ); + } +} + +async function AssertPrivateResult(win, engine, isPrivateEngine) { + let count = await UrlbarTestUtils.getResultCount(win); + Assert.ok(count > 1, "Sanity check result count"); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Check result type" + ); + Assert.ok(result.searchParams.inPrivateWindow, "Check inPrivateWindow"); + Assert.equal( + result.searchParams.isPrivateEngine, + isPrivateEngine, + "Check isPrivateEngine" + ); + Assert.equal( + result.searchParams.engine, + engine.name, + "Check the search engine" + ); + return result; +} + +// Tests from here on have a different default private engine. + +add_task(async function test_search_private_engine() { + info( + "Test that 'Search in a Private Window' reports a separate private engine" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult(window, gPrivateEngine, true); +}); + +add_task(async function test_privateWindow() { + info( + "Test that 'Search in a Private Window' does not appear in a private window" + ); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: "unique198273982173", + }); + await AssertNoPrivateResult(privateWin); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_permanentPB() { + info( + "Test that 'Search in a Private Window' does not appear in Permanent Private Browsing" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "unique198273982173", + }); + await AssertNoPrivateResult(win); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_openPBWindow() { + info( + "Test that 'Search in a Private Window' opens the search in a new Private Window" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "unique198273982173", + }); + await AssertPrivateResult( + window, + await Services.search.getDefaultPrivate(), + true + ); + + await withHttpServer(serverInfo, async () => { + let promiseWindow = BrowserTestUtils.waitForNewWindow({ + url: "http://localhost:20709/?terms=unique198273982173", + maybeErrorPage: true, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("VK_RETURN"); + let win = await promiseWindow; + Assert.ok( + PrivateBrowsingUtils.isWindowPrivate(win), + "Should open a private window" + ); + await BrowserTestUtils.closeWindow(win); + }); +}); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_with_private_engine_mouse() { +// info( +// "Test that 'Search in a Private Window' opens the private engine even if a one-off is selected" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult( +// window, +// await Services.search.getDefaultPrivate(), +// true +// ); + +// await withHttpServer(serverInfo, async () => { +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Click on the result. It should open a pb window using +// // the private search engine, because it has been set. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: "http://localhost:20709/?terms=unique198273982173", +// maybeErrorPage: true, +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// // Click on the result. +// let element = UrlbarTestUtils.getSelectedRow(window); +// EventUtils.synthesizeMouseAtCenter(element, {}); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// }); +// await SpecialPowers.popPrefEnv(); +// }); + +// TODO: (Bug 1658620) Write a new subtest for this behaviour with the update2 +// pref on. +// add_task(async function test_oneoff_selected_with_private_engine_keyboard() { +// info( +// "Test that 'Search in a Private Window' opens the private engine even if a one-off is selected" +// ); +// await SpecialPowers.pushPrefEnv({ +// set: [ +// ["browser.urlbar.update2", false], +// ["browser.urlbar.update2.oneOffsRefresh", false], +// ], +// }); +// await UrlbarTestUtils.promiseAutocompleteResultPopup({ +// window, +// value: "unique198273982173", +// }); +// await AssertPrivateResult( +// window, +// await Services.search.getDefaultPrivate(), +// true +// ); + +// await withHttpServer(serverInfo, async () => { +// // Select the 'Search in a Private Window' result, alt down to select the +// // first one-off button, Enter. It should open a pb window, but using the +// // selected one-off engine. +// let promiseWindow = BrowserTestUtils.waitForNewWindow({ +// url: "http://localhost:20709/?terms=unique198273982173", +// maybeErrorPage: true, +// }); +// // Select the private result. +// EventUtils.synthesizeKey("KEY_ArrowDown"); +// // Select the first one-off button. +// EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); +// EventUtils.synthesizeKey("VK_RETURN"); +// let win = await promiseWindow; +// Assert.ok( +// PrivateBrowsingUtils.isWindowPrivate(win), +// "Should open a private window" +// ); +// await BrowserTestUtils.closeWindow(win); +// }); +// await SpecialPowers.popPrefEnv(); +// }); + +add_task(async function test_alias_no_query() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + info( + "Test that 'Search in a Private Window' doesn't appear if an alias is typed with no query" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "alias ", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: gAliasEngine.name, + entry: "typed", + }); + await AssertNoPrivateResult(window); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_alias_query() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.update2.emptySearchBehavior", 2]], + }); + info( + "Test that 'Search in a Private Window' appears when an alias is typed with a query" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "alias something", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "MozSearch", + entry: "typed", + }); + await AssertPrivateResult(window, gAliasEngine, true); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_restrict() { + info( + "Test that 'Search in a Private Window' doesn's appear for just the restriction token" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH, + }); + await AssertNoPrivateResult(window); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH + " ", + }); + await AssertNoPrivateResult(window); + await UrlbarTestUtils.exitSearchMode(window); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: " " + UrlbarTokenizer.RESTRICT.SEARCH, + }); + await AssertNoPrivateResult(window); +}); + +add_task(async function test_restrict_search() { + info( + "Test that 'Search in a Private Window' has the right string with the restriction token" + ); + let engine = await Services.search.getDefaultPrivate(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: UrlbarTokenizer.RESTRICT.SEARCH + "test", + }); + let result = await AssertPrivateResult(window, engine, true); + Assert.equal(result.searchParams.query, "test"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test" + UrlbarTokenizer.RESTRICT.SEARCH, + }); + result = await AssertPrivateResult(window, engine, true); + Assert.equal(result.searchParams.query, "test"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js b/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js new file mode 100644 index 0000000000..1fef68de30 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_shortcuts_add_search_engine.js @@ -0,0 +1,243 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test adding engines through search shortcut buttons. +// A more complete coverage of the detection of engines is available in +// browser_add_search_engine.js + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + // Ensure initial state. + UrlbarTestUtils.getOneOffSearchButtons(window).invalidateCache(); +}); + +add_task(async function shortcuts_none() { + info("Checks the shortcuts with a page that doesn't offer any engines."); + let url = "http://mochi.test:8888/"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + Assert.ok( + !Array.from(shortcutButtons.buttons.children).some(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ), + "Check there's no buttons to add engines" + ); + }); +}); + +add_task(async function test_shortcuts() { + await do_test_shortcuts(button => { + info("Click on button"); + EventUtils.synthesizeMouseAtCenter(button, {}); + }); + await do_test_shortcuts(button => { + info("Enter on button"); + let shortcuts = UrlbarTestUtils.getOneOffSearchButtons(window); + while (shortcuts.selectedButton != button) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + } + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +/** + * Test add engine shortcuts. + * + * @param {Function} activateTask a function receiveing the shortcut button to + * activate as argument. The scope of this function is to activate the + * shortcut button. + */ +async function do_test_shortcuts(activateTask) { + info("Checks the shortcuts with a page that offers two engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_two.html"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + let addEngineButtons = Array.from(shortcutButtons.buttons.children).filter( + b => b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + 2, + "Check there's two buttons to add engines" + ); + + for (let button of addEngineButtons) { + Assert.ok(BrowserTestUtils.is_visible(button)); + Assert.ok(button.hasAttribute("image")); + await document.l10n.translateElements([button]); + Assert.ok( + button.getAttribute("tooltiptext").includes("add_search_engine_") + ); + Assert.ok( + button.getAttribute("engine-name").startsWith("add_search_engine_") + ); + Assert.ok( + button.classList.contains("searchbar-engine-one-off-add-engine") + ); + } + + info("Activate the first button"); + rebuildPromise = BrowserTestUtils.waitForEvent(shortcutButtons, "rebuild"); + let enginePromise = promiseEngine("engine-added", "add_search_engine_0"); + await activateTask(addEngineButtons[0]); + info("await engine install"); + let engine = await enginePromise; + info("await rebuild"); + await rebuildPromise; + + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "Urlbar view is still open." + ); + + addEngineButtons = Array.from(shortcutButtons.buttons.children).filter(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + 1, + "Check there's one button to add engines" + ); + Assert.equal( + addEngineButtons[0].getAttribute("engine-name"), + "add_search_engine_1" + ); + let installedEngineButton = addEngineButtons[0].previousElementSibling; + Assert.equal(installedEngineButton.engine.name, "add_search_engine_0"); + + info("Remove the added engine"); + rebuildPromise = BrowserTestUtils.waitForEvent(shortcutButtons, "rebuild"); + await Services.search.removeEngine(engine); + await rebuildPromise; + Assert.equal( + Array.from(shortcutButtons.buttons.children).filter(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ).length, + 2, + "Check there's two buttons to add engines" + ); + await UrlbarTestUtils.promisePopupClose(window); + + info("Switch to a new tab and check the buttons are not persisted"); + await BrowserTestUtils.withNewTab("about:robots", async () => { + rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + Assert.ok( + !Array.from(shortcutButtons.buttons.children).some(b => + b.classList.contains("searchbar-engine-one-off-add-engine") + ), + "Check there's no option to add engines" + ); + }); + }); +} + +add_task(async function shortcuts_many() { + info("Checks the shortcuts with a page that offers many engines."); + let url = getRootDirectory(gTestPath) + "add_search_engine_many.html"; + await BrowserTestUtils.withNewTab(url, async () => { + let shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + let rebuildPromise = BrowserTestUtils.waitForEvent( + shortcutButtons, + "rebuild" + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await rebuildPromise; + + let addEngineButtons = Array.from(shortcutButtons.buttons.children).filter( + b => b.classList.contains("searchbar-engine-one-off-add-engine") + ); + Assert.equal( + addEngineButtons.length, + gURLBar.addSearchEngineHelper.maxInlineEngines, + "Check there's a maximum of `maxInlineEngines` buttons to add engines" + ); + }); +}); + +function promiseEngine(expectedData, expectedEngineName) { + info(`Waiting for engine ${expectedData}`); + return TestUtils.topicObserved( + "browser-search-engine-modified", + (engine, data) => { + info(`Got engine ${engine.wrappedJSObject.name} ${data}`); + return ( + expectedData == data && + expectedEngineName == engine.wrappedJSObject.name + ); + } + ).then(([engine, data]) => engine); +} + +add_task(async function shortcuts_without_other_engines() { + info("Checks the shortcuts without other engines."); + + info("Remove search engines except default"); + const defaultEngine = Services.search.defaultEngine; + const engines = await Services.search.getVisibleEngines(); + for (const engine of engines) { + if (defaultEngine.name !== engine.name) { + await Services.search.removeEngine(engine); + } + } + + info("Remove local engines"); + for (const { pref } of UrlbarUtils.LOCAL_SEARCH_MODES) { + await SpecialPowers.pushPrefEnv({ + set: [[`browser.urlbar.${pref}`, false]], + }); + } + + const url = getRootDirectory(gTestPath) + "add_search_engine_many.html"; + await BrowserTestUtils.withNewTab(url, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + + const shortcutButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(shortcutButtons.container.hidden, "It should be hidden"); + }); + + Services.search.restoreDefaultEngines(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_speculative_connect.js b/browser/components/urlbar/tests/browser/browser_speculative_connect.js new file mode 100644 index 0000000000..3b98169699 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect.js @@ -0,0 +1,199 @@ +/* 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/. */ + +"use strict"; + +// This test ensures that we setup a speculative network connection to +// the site in various cases: +// 1. search engine if it's the first result +// 2. mousedown event before the http request happens(in mouseup). + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine2.xml"; + +const serverInfo = { + scheme: "http", + host: "localhost", + port: 20709, // Must be identical to what is in searchSuggestionEngine2.xml +}; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.speculativeConnect.enabled", true], + // In mochitest this number is 0 by default but we have to turn it on. + ["network.http.speculative-parallel-limit", 6], + // The http server is using IPv4, so it's better to disable IPv6 to avoid + // weird networking problem. + ["network.dns.disableIPv6", true], + ], + }); + + // Ensure we start from a clean situation. + await PlacesUtils.history.clear(); + + await PlacesTestUtils.addVisits([ + { + uri: `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}`, + title: "test visit for speculative connection", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function search_test() { + // We speculative connect to the search engine only if suggestions are enabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", true]], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + info("Searching for 'foo'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + // Check if the first result is with type "searchengine" + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The first result is a search" + ); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function popup_mousedown_test() { + // Disable search suggestions and autofill, to avoid other speculative + // connections. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", false], + ["browser.urlbar.autoFill", false], + ], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = "ocal"; + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + info(`Searching for '${searchString}'`); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let listitem = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + details.url, + completeValue, + "The second item has the url we visited." + ); + + info("Clicking on the second result"); + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window); + Assert.equal( + UrlbarTestUtils.getSelectedRow(window), + listitem, + "The second item is selected" + ); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function test_autofill() { + // Disable search suggestions but enable autofill. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", false], + ["browser.urlbar.autoFill", true], + ], + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = serverInfo.host; + info(`Searching for '${searchString}'`); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + Assert.equal(details.url, completeValue, `Autofilled value is as expected`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + 1 + ); + }); +}); + +add_task(async function test_autofill_privateContext() { + info("Autofill in private context."); + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + registerCleanupFunction(async () => { + let promisePBExit = TestUtils.topicObserved("last-pb-context-exited"); + await BrowserTestUtils.closeWindow(privateWin); + await promisePBExit; + }); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + let searchString = serverInfo.host; + info(`Searching for '${searchString}'`); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: searchString, + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(privateWin, 0); + let completeValue = `${serverInfo.scheme}://${serverInfo.host}:${serverInfo.port}/`; + Assert.equal(details.url, completeValue, `Autofilled value is as expected`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + ); + }); +}); + +add_task(async function test_no_heuristic_result() { + info("Don't speculative connect on results addition if there's no heuristic"); + await withHttpServer(serverInfo, async server => { + let connectionNumber = server.connectionNumber; + info(`Searching for the empty string`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + ok(UrlbarTestUtils.getResultCount(window) > 0, "Has results"); + let result = await UrlbarTestUtils.getSelectedRow(window); + Assert.strictEqual(result, null, `Should have no selection`); + await UrlbarTestUtils.promiseSpeculativeConnections( + server, + connectionNumber + ); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js b/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js new file mode 100644 index 0000000000..de352efb59 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js @@ -0,0 +1,236 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* 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/. */ + +"use strict"; + +// Tests that we don't speculatively connect when user certificates are installed + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const certOverrideService = Cc[ + "@mozilla.org/security/certoverride;1" +].getService(Ci.nsICertOverrideService); + +const host = "localhost"; +let uri; +let handshakeDone = false; +let expectingChooseCertificate = false; +let chooseCertificateCalled = false; + +const clientAuthDialogs = { + chooseCertificate( + hostname, + port, + organization, + issuerOrg, + certList, + selectedIndex, + rememberClientAuthCertificate + ) { + ok( + expectingChooseCertificate, + `${ + expectingChooseCertificate ? "" : "not " + }expecting chooseCertificate to be called` + ); + is(certList.length, 1, "should have only one client certificate available"); + selectedIndex.value = 0; + rememberClientAuthCertificate.value = false; + ok( + !chooseCertificateCalled, + "chooseCertificate should only be called once" + ); + chooseCertificateCalled = true; + return true; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogs"]), +}; + +/** + * A helper class to use with nsITLSServerConnectionInfo.setSecurityObserver. + * Implements nsITLSServerSecurityObserver and simulates an extremely + * rudimentary HTTP server that expects an HTTP/1.1 GET request and responds + * with a 200 OK. + */ +class SecurityObserver { + constructor(input, output) { + this.input = input; + this.output = output; + } + + onHandshakeDone(socket, status) { + info("TLS handshake done"); + handshakeDone = true; + + let output = this.output; + this.input.asyncWait( + { + onInputStreamReady(readyInput) { + try { + let request = NetUtil.readInputStreamToString( + readyInput, + readyInput.available() + ); + ok( + request.startsWith("GET /") && request.includes("HTTP/1.1"), + "expecting an HTTP/1.1 GET request" + ); + let response = + "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\n" + + "Connection:Close\r\nContent-Length:2\r\n\r\nOK"; + output.write(response, response.length); + } catch (e) { + console.log(e.message); + // This will fail when we close the speculative connection. + } + }, + }, + 0, + 0, + Services.tm.currentThread + ); + } +} + +function startServer(cert) { + let tlsServer = Cc["@mozilla.org/network/tls-server-socket;1"].createInstance( + Ci.nsITLSServerSocket + ); + tlsServer.init(-1, true, -1); + tlsServer.serverCert = cert; + + let securityObservers = []; + + let listener = { + onSocketAccepted(socket, transport) { + info("Accepted TLS client connection"); + let connectionInfo = transport.securityCallbacks.getInterface( + Ci.nsITLSServerConnectionInfo + ); + let input = transport.openInputStream(0, 0, 0); + let output = transport.openOutputStream(0, 0, 0); + connectionInfo.setSecurityObserver(new SecurityObserver(input, output)); + }, + + onStopListening() { + info("onStopListening"); + for (let securityObserver of securityObservers) { + securityObserver.input.close(); + securityObserver.output.close(); + } + }, + }; + + tlsServer.setSessionTickets(false); + tlsServer.setRequestClientCertificate(Ci.nsITLSServerSocket.REQUEST_ALWAYS); + + tlsServer.asyncListen(listener); + + return tlsServer; +} + +let server; + +function getTestServerCertificate() { + const certDB = Cc["@mozilla.org/security/x509certdb;1"].getService( + Ci.nsIX509CertDB + ); + for (const cert of certDB.getCerts()) { + if (cert.commonName == "Mochitest client") { + return cert; + } + } + return null; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + // Turn off search suggestion so we won't speculative connect to the search engine. + ["browser.search.suggest.enabled", false], + ["browser.urlbar.speculativeConnect.enabled", true], + // In mochitest this number is 0 by default but we have to turn it on. + ["network.http.speculative-parallel-limit", 6], + // The http server is using IPv4, so it's better to disable IPv6 to avoid weird + // networking problem. + ["network.dns.disableIPv6", true], + ["security.default_personal_cert", "Ask Every Time"], + ], + }); + + let clientAuthDialogsCID = MockRegistrar.register( + "@mozilla.org/nsClientAuthDialogs;1", + clientAuthDialogs + ); + + let cert = getTestServerCertificate(); + server = startServer(cert); + uri = `https://${host}:${server.port}/`; + info(`running tls server at ${uri}`); + await PlacesTestUtils.addVisits([ + { + uri, + title: "test visit for speculative connection", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + certOverrideService.rememberValidityOverride( + "localhost", + server.port, + {}, + cert, + true + ); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + MockRegistrar.unregister(clientAuthDialogsCID); + certOverrideService.clearValidityOverride("localhost", server.port, {}); + }); +}); + +add_task( + async function popup_mousedown_no_client_cert_dialog_until_navigate_test() { + // To not trigger autofill, search keyword starts from the second character. + let searchString = host.substr(1, 4); + let completeValue = uri; + info(`Searching for '${searchString}'`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + let listitem = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + info(`The url of the second item is ${details.url}`); + is(details.url, completeValue, "The second item has the url we visited."); + + expectingChooseCertificate = false; + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mousedown" }, window); + is( + UrlbarTestUtils.getSelectedRow(window), + listitem, + "The second item is selected" + ); + + // We shouldn't have triggered a speculative connection, because a client + // certificate is installed. + SimpleTest.requestFlakyTimeout("Wait for UI"); + await new Promise(resolve => setTimeout(resolve, 200)); + + // Now mouseup, expect that we choose a client certificate, and expect that + // we successfully load a page. + expectingChooseCertificate = true; + EventUtils.synthesizeMouseAtCenter(listitem, { type: "mouseup" }, window); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + ok(chooseCertificateCalled, "chooseCertificate must have been called"); + server.close(); + } +); diff --git a/browser/components/urlbar/tests/browser/browser_stop.js b/browser/components/urlbar/tests/browser/browser_stop.js new file mode 100644 index 0000000000..285071a3ff --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stop.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests ensures the urlbar reflects the correct value if a page load is + * stopped immediately after loading. + */ + +"use strict"; + +const goodURL = "http://mochi.test:8888/"; +const badURL = "http://mochi.test:8888/whatever.html"; + +add_task(async function () { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, goodURL); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gURLBar.value, + BrowserUIUtils.trimURL(goodURL), + "location bar reflects loaded page" + ); + + await typeAndSubmitAndStop(badURL); + is( + gURLBar.value, + BrowserUIUtils.trimURL(goodURL), + "location bar reflects loaded page after stop()" + ); + gBrowser.removeCurrentTab(); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + is(gURLBar.value, "", "location bar is empty"); + + await typeAndSubmitAndStop(badURL); + is( + gURLBar.value, + BrowserUIUtils.trimURL(badURL), + "location bar reflects stopped page in an empty tab" + ); + gBrowser.removeCurrentTab(); +}); + +async function typeAndSubmitAndStop(url) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: url, + fireInputEvent: true, + }); + + let docLoadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + url, + gBrowser.selectedBrowser + ); + + // When the load is stopped, tabbrowser calls gURLBar.setURI and then calls + // onStateChange on its progress listeners. So to properly wait until the + // urlbar value has been updated, add our own progress listener here. + let progressPromise = new Promise(resolve => { + let listener = { + onStateChange(browser, webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ) { + gBrowser.removeTabsProgressListener(listener); + resolve(); + } + }, + }; + gBrowser.addTabsProgressListener(listener); + }); + + gURLBar.handleCommand(); + await Promise.all([docLoadPromise, progressPromise]); +} diff --git a/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js b/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js new file mode 100644 index 0000000000..0a1ef1b057 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js @@ -0,0 +1,113 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests that when a search is stopped due to the user selecting a result, + * the view doesn't update after that. + */ + +"use strict"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngineSlow.xml"; + +// This should match the `timeout` query param used in the suggestions URL in +// the test engine. +const TEST_ENGINE_SUGGESTIONS_TIMEOUT = 3000; + +// The number of suggestions returned by the test engine. +const TEST_ENGINE_NUM_EXPECTED_RESULTS = 2; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + // Add a test search engine that returns suggestions on a delay. + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function mainTest() { + // Open a tab that will match the search string below so that we're guaranteed + // to have more than one result (the heuristic result) so that we can change + // the selected result. We open a tab instead of adding a page in history + // because open tabs are kept in a memory SQLite table, so open-tab results + // are more likely than history results to be fetched before our slow search + // suggestions. This is important when the test runs on slow debug builds on + // slow machines. + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do an initial search. There should be 4 results: heuristic, open tab, + // and the two suggestions. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "amp", + }); + await TestUtils.waitForCondition(() => { + return ( + UrlbarTestUtils.getResultCount(window) == + 2 + TEST_ENGINE_NUM_EXPECTED_RESULTS + ); + }); + + // Type a character to start a new search. The new search should still + // match the open tab so that the open-tab result appears again. + EventUtils.synthesizeKey("l"); + + // There should be 2 results immediately: heuristic and open tab. + await TestUtils.waitForCondition(() => { + return UrlbarTestUtils.getResultCount(window) == 2; + }); + + // Before the search completes, change the selected result. Pressing only + // the down arrow key ends up selecting the first one-off on Linux debug + // builds on the infrastructure for some reason, so arrow back up to + // select the heuristic result again. The important thing is to change + // the selection. It doesn't matter which result ends up selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Wait for the new search to complete. It should be canceled due to the + // selection change, but it should still complete. + await UrlbarTestUtils.promiseSearchComplete(window); + + // To make absolutely sure the suggestions don't appear after the search + // completes, wait a bit. + await new Promise(r => + setTimeout(r, 1 + TEST_ENGINE_SUGGESTIONS_TIMEOUT) + ); + + // The heuristic result should reflect the new search, "ampl". + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have the correct result type" + ); + Assert.equal( + result.searchParams.query, + "ampl", + "Should have the correct query" + ); + + // None of the other results should be "ampl" suggestions, i.e., amplfoo + // and amplbar should not be in the results. + let count = UrlbarTestUtils.getResultCount(window); + for (let i = 1; i < count; i++) { + result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + !["amplfoo", "amplbar"].includes(result.searchParams.suggestion), + "Suggestions should not contain the typed l char" + ); + } + }); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_stop_pending.js b/browser/components/urlbar/tests/browser/browser_stop_pending.js new file mode 100644 index 0000000000..2e7056e972 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stop_pending.js @@ -0,0 +1,459 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://www.example.com" + ) + "slow-page.sjs"; +const SLOW_PAGE2 = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888" + ) + "slow-page.sjs?faster"; + +/** + * Check that if we: + * 1) have a loaded page + * 2) load a separate URL + * 3) before the URL for step 2 has finished loading, load a third URL + * we don't revert to the URL from (1). + */ +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com", + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let handler = () => { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + expectedURLBarChange = SLOW_PAGE2; + let pageLoadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + gURLBar.value = expectedURLBarChange; + gURLBar.handleCommand(); + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should not have changed URL bar value synchronously." + ); + await pageLoadPromise; + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that if we: + * 1) middle-click a link to a separate page whose server doesn't respond + * 2) we switch to that tab and stop the request + * + * The URL bar continues to contain the URL of the page we wanted to visit. + */ +add_task(async function () { + let socket = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket.init(-1, true, -1); + const PORT = socket.port; + registerCleanupFunction(() => { + socket.close(); + }); + + const BASE_PAGE = TEST_BASE_URL + "dummy_page.html"; + const SLOW_HOST = `https://localhost:${PORT}/`; + info("Using URLs: " + SLOW_HOST); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BASE_PAGE); + info("opened tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [SLOW_HOST], URL => { + let link = content.document.createElement("a"); + link.href = URL; + link.textContent = "click me to open a slow page"; + link.id = "clickme"; + content.document.body.appendChild(link); + }); + info("added link"); + let newTabPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + // Middle click the link: + await BrowserTestUtils.synthesizeMouseAtCenter( + "#clickme", + { button: 1 }, + tab.linkedBrowser + ); + // get new tab, switch to it + let newTab = (await newTabPromise).target; + await BrowserTestUtils.switchTab(gBrowser, newTab); + is(gURLBar.untrimmedValue, SLOW_HOST, "Should have slow page in URL bar"); + let browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + BrowserStop(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST, + "Should still have slow page in URL bar after stop" + ); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); +/** + * Check that if we: + * 1) middle-click a link to a separate page whose server doesn't respond + * 2) we alter the URL on that page to some other server that doesn't respond + * 3) we stop the request + * + * The URL bar continues to contain the second URL. + */ +add_task(async function () { + let socket = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket.init(-1, true, -1); + const PORT1 = socket.port; + let socket2 = Cc["@mozilla.org/network/server-socket;1"].createInstance( + Ci.nsIServerSocket + ); + socket2.init(-1, true, -1); + const PORT2 = socket2.port; + registerCleanupFunction(() => { + socket.close(); + socket2.close(); + }); + + const BASE_PAGE = TEST_BASE_URL + "dummy_page.html"; + const SLOW_HOST1 = `https://localhost:${PORT1}/`; + const SLOW_HOST2 = `https://localhost:${PORT2}/`; + info("Using URLs: " + SLOW_HOST1 + " and " + SLOW_HOST2); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BASE_PAGE); + info("opened tab"); + await SpecialPowers.spawn(tab.linkedBrowser, [SLOW_HOST1], URL => { + let link = content.document.createElement("a"); + link.href = URL; + link.textContent = "click me to open a slow page"; + link.id = "clickme"; + content.document.body.appendChild(link); + }); + info("added link"); + let newTabPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabOpen" + ); + // Middle click the link: + await BrowserTestUtils.synthesizeMouseAtCenter( + "#clickme", + { button: 1 }, + tab.linkedBrowser + ); + // get new tab, switch to it + let newTab = (await newTabPromise).target; + await BrowserTestUtils.switchTab(gBrowser, newTab); + is(gURLBar.untrimmedValue, SLOW_HOST1, "Should have slow page in URL bar"); + let browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + gURLBar.value = SLOW_HOST2; + gURLBar.handleCommand(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST2, + "Should have second slow page in URL bar" + ); + browserStoppedPromise = BrowserTestUtils.browserStopped( + newTab.linkedBrowser, + null, + true + ); + BrowserStop(); + await browserStoppedPromise; + + is( + gURLBar.untrimmedValue, + SLOW_HOST2, + "Should still have second slow page in URL bar after stop" + ); + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load page 0 and wait for it to finish loading. + * 2) Try to load page 1 and wait for it to finish loading. + * 3) Try to load SLOW_PAGE, and then before it finishes loading, navigate back. + * - We should be taken to page 0. + */ +add_task(async function testCorrectUrlBarAfterGoingBackDuringAnotherLoad() { + // Load example.org + let page0 = "http://example.org/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page0, + true, + true + ); + + // Load example.com in the same browser + let page1 = "http://example.com/"; + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, page1); + BrowserTestUtils.loadURIString(tab.linkedBrowser, page1); + await loaded; + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let goneBack = false; + let handler = () => { + if (!goneBack) { + isnot( + gURLBar.untrimmedValue, + initialValue, + `Should not revert URL bar value to ${initialValue}` + ); + } + + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + `Should set expected URL bar value - ${expectedURLBarChange}` + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Set the value of url bar to SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test case: + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + expectedURLBarChange = page0; + let pageLoadPromise = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + page0 + ); + + // Wait until we can go back + await TestUtils.waitForCondition(() => tab.linkedBrowser.canGoBack); + ok(tab.linkedBrowser.canGoBack, "can go back"); + + // Navigate back from SLOW_PAGE. We should be taken to page 0 now. + tab.linkedBrowser.goBack(); + goneBack = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait until page 0 have finished loading. + await pageLoadPromise; + is( + gURLBar.untrimmedValue, + page0, + "Should not have changed URL bar value synchronously." + ); + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load page 1 and wait for it to finish loading. + * 2) Start loading SLOW_PAGE (it won't finish loading) + * 3) Reload the page. We should have loaded page 1 now. + */ +add_task(async function testCorrectUrlBarAfterReloadingDuringSlowPageLoad() { + // Load page 1 - example.com + let page1 = "http://example.com/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page1, + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let hasReloaded = false; + let handler = () => { + if (!hasReloaded) { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + } + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Start loading SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test: If this ever starts going intermittent, + // we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + expectedURLBarChange = page1; + let pageLoadPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + page1 + ); + // Reload the page + tab.linkedBrowser.reload(); + hasReloaded = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait for page1 to be loaded due to a reload while the slow page was still loading + await pageLoadPromise; + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); + +/** + * 1) Try to load example.com and wait for it to finish loading. + * 2) Start loading SLOW_PAGE and then stop the load before the load completes + * 3) Check that example.com has been loaded as a result of stopping SLOW_PAGE + * load. + */ +add_task(async function testCorrectUrlBarAfterStoppingTheLoad() { + // Load page 1 + let page1 = "http://example.com/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + page1, + true, + true + ); + + let initialValue = gURLBar.untrimmedValue; + let expectedURLBarChange = SLOW_PAGE; + let sawChange = false; + let hasStopped = false; + let handler = () => { + if (!hasStopped) { + isnot( + gURLBar.untrimmedValue, + initialValue, + "Should not revert URL bar value!" + ); + } + if (gURLBar.getAttribute("pageproxystate") == "valid") { + sawChange = true; + is( + gURLBar.untrimmedValue, + expectedURLBarChange, + "Should set expected URL bar value!" + ); + } + }; + + let obs = new MutationObserver(handler); + + obs.observe(gURLBar.textbox, { attributes: true }); + // Start loading SLOW_PAGE + gURLBar.value = SLOW_PAGE; + gURLBar.handleCommand(); + + // Copied from the first test case: + // If this ever starts going intermittent, we've broken this. + await new Promise(resolve => setTimeout(resolve, 200)); + + // We expect page 1 to be loaded after the SLOW_PAGE load is stopped. + expectedURLBarChange = page1; + let pageLoadPromise = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + SLOW_PAGE, + true + ); + // Stop the SLOW_PAGE load + tab.linkedBrowser.stop(); + hasStopped = true; + is( + gURLBar.untrimmedValue, + SLOW_PAGE, + "Should not have changed URL bar value synchronously." + ); + // Wait for SLOW_PAGE load to stop + await pageLoadPromise; + + ok( + sawChange, + "The URL bar change handler should have been called by the time the page was loaded" + ); + obs.disconnect(); + obs = null; + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_strip_on_share.js b/browser/components/urlbar/tests/browser/browser_strip_on_share.js new file mode 100644 index 0000000000..39a3e23aa7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_strip_on_share.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let listService; + +// Tests for the strip on share functionality of the urlbar. + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.query_stripping.strip_list", "stripParam"], + ["privacy.query_stripping.enabled", false], + ], + }); + + // Get the list service so we can wait for it to be fully initialized before running tests. + listService = Cc["@mozilla.org/query-stripping-list-service;1"].getService( + Ci.nsIURLQueryStrippingListService + ); + + await listService.testWaitForInit(); +}); + +// Menu item should be visible, the whole url is copied without a selection, url should be stripped. +add_task(async function testQueryParamIsStripped() { + await testMenuItemEnabled(false); +}); + +// Menu item should be visible, selecting the whole url, url should be stripped. +add_task(async function testQueryParamIsStrippedSelectURL() { + await testMenuItemEnabled(true); +}); + +// We cannot strip anything, menu item should be hidden +add_task(async function testUnknownQueryParam() { + await testMenuItemDisabled( + "https://www.example.com/?noStripParam=1234", + true, + false + ); +}); + +// Selection is not a valid URI, menu item should be hidden +add_task(async function testInvalidURI() { + await testMenuItemDisabled( + "https://www.example.com/?stripParam=1234", + true, + true + ); +}); + +// Pref is not enabled, menu item should be hidden +add_task(async function testPrefDisabled() { + await testMenuItemDisabled( + "https://www.example.com/?stripParam=1234", + false, + false + ); +}); + +/** + * Opens a new tab, opens the ulr bar context menu and checks that the strip-on-share menu item is not visible + * + * @param {string} url - The url to be loaded + * @param {boolean} prefEnabled - Whether privacy.query_stripping.strip_on_share.enabled should be enabled for the test + * @param {boolean} selection - True: The whole url will be selected, false: Only part of the url will be selected + */ +async function testMenuItemDisabled(url, prefEnabled, selection) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.query_stripping.strip_on_share.enabled", prefEnabled]], + }); + await BrowserTestUtils.withNewTab(url, async function (browser) { + gURLBar.focus(); + if (selection) { + //select only part of the url + gURLBar.selectionStart = url.indexOf("example"); + gURLBar.selectionEnd = url.indexOf("4"); + } + let menuitem = await promiseContextualMenuitem("strip-on-share"); + Assert.ok( + !BrowserTestUtils.is_visible(menuitem), + "Menu item is not visible" + ); + let hidePromise = BrowserTestUtils.waitForEvent( + menuitem.parentElement, + "popuphidden" + ); + menuitem.parentElement.hidePopup(); + await hidePromise; + }); +} + +/** + * Opens a new tab, opens the url bar context menu and checks that the strip-on-share menu item is visible. + * Checks that the stripped version of the url is copied to the clipboard. + * + * @param {boolean} selectWholeUrl - Whether the whole url should be explicitely selected + */ +async function testMenuItemEnabled(selectWholeUrl) { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.query_stripping.strip_on_share.enabled", true]], + }); + let validUrl = "https://www.example.com/?stripParam=1234"; + let strippedUrl = "https://www.example.com/"; + await BrowserTestUtils.withNewTab(validUrl, async function (browser) { + gURLBar.focus(); + if (selectWholeUrl) { + //select the whole url + gURLBar.select(); + } + let menuitem = await promiseContextualMenuitem("strip-on-share"); + Assert.ok(BrowserTestUtils.is_visible(menuitem), "Menu item is visible"); + let hidePromise = BrowserTestUtils.waitForEvent( + menuitem.parentElement, + "popuphidden" + ); + // Make sure the clean copy of the link will be copied to the clipboard + await SimpleTest.promiseClipboardChange(strippedUrl, () => { + menuitem.closest("menupopup").activateItem(menuitem); + }); + await hidePromise; + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_suggestedIndex.js b/browser/components/urlbar/tests/browser/browser_suggestedIndex.js new file mode 100644 index 0000000000..563202036a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_suggestedIndex.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that results with a suggestedIndex property end up in the expected +// position. + +add_task(async function suggestedIndex() { + let result1 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/1" } + ); + result1.suggestedIndex = 2; + let result2 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/2" } + ); + result2.suggestedIndex = 6; + + let provider = new UrlbarTestUtils.TestProvider({ + results: [result1, result2], + }); + UrlbarProvidersManager.registerProvider(provider); + async function clean() { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + } + registerCleanupFunction(clean); + + let urls = []; + let maxResults = UrlbarPrefs.get("maxRichResults"); + // Add more results, so that the sum of these results plus the above ones, + // will be greater than maxResults. + for (let i = 0; i < maxResults; ++i) { + urls.push("http://example.com/foo" + i); + } + await PlacesTestUtils.addVisits(urls); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + `There should be ${maxResults} results in the view.` + ); + + urls.reverse(); + urls.unshift( + (await Services.search.getDefault()).getSubmission("foo").uri.spec + ); + urls.splice(result1.suggestedIndex, 0, result1.payload.url); + urls.splice(result2.suggestedIndex, 0, result2.payload.url); + urls = urls.slice(0, maxResults); + + let expected = []; + for (let i = 0; i < maxResults; ++i) { + let url = (await UrlbarTestUtils.getDetailsOfResultAt(window, i)).url; + expected.push(url); + } + // Check all the results. + Assert.deepEqual(expected, urls); + + await clean(); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function suggestedIndex_append() { + // When suggestedIndex is greater than the number of results the result is + // appended. + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/append/" } + ); + result.suggestedIndex = 4; + + let provider = new UrlbarTestUtils.TestProvider({ results: [result] }); + UrlbarProvidersManager.registerProvider(provider); + async function clean() { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + } + registerCleanupFunction(clean); + + await PlacesTestUtils.addVisits("http://example.com/bar"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "bar", + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 3, + `There should be 3 results in the view.` + ); + + let urls = [ + (await Services.search.getDefault()).getSubmission("bar").uri.spec, + "http://example.com/bar", + "http://mozilla.org/append/", + ]; + + let expected = []; + for (let i = 0; i < 3; ++i) { + let url = (await UrlbarTestUtils.getDetailsOfResultAt(window, i)).url; + expected.push(url); + } + // Check all the results. + Assert.deepEqual(expected, urls); + + await clean(); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js b/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js new file mode 100644 index 0000000000..c8d84e5c4c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_suppressFocusBorder.js @@ -0,0 +1,391 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that the suppress-focus-border attribute is applied to the Urlbar + * correctly. Its purpose is to hide the focus border after the panel is closed. + * It also ensures we don't flash the border at the user after they click the + * Urlbar but before we decide we're opening the view. + */ + +let TEST_RESULT = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/" } +); + +/** + * A test provider that awaits a promise before returning results. + */ +class AwaitPromiseProvider extends UrlbarTestUtils.TestProvider { + /** + * @param {object} args + * The constructor arguments for UrlbarTestUtils.TestProvider. + * @param {Promise} promise + * The promise that will be awaited before returning results. + */ + constructor(args, promise) { + super(args); + this._promise = promise; + } + + async startQuery(context, add) { + await this._promise; + for (let result of this._results) { + add(this, result); + } + } +} + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(function () { + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function afterMousedown_topSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gURLBar.blur(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupOpen(win, () => { + if (win.gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }); + + let result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok( + result, + "The provider returned a result after waiting for the suppress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + !gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute after close." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function openLocation_topSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("l", { accelKey: true }, win); + }); + + let result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok( + result, + "The provider returned a result after waiting for the suppress-focus-border attribute." + ); + + await UrlbarTestUtils.promisePopupClose(win); + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute after close." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that the address bar loses the suppress-focus-border attribute if no +// results are returned by a query. This simulates the user disabling Top Sites +// then clicking the address bar. +add_task(async function afterMousedown_noTopSites() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + // Note that the provider returns no results. + { results: [], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "Sanity check: the Urlbar does not have the supress-focus-border attribute." + ); + + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!UrlbarTestUtils.isPopupOpen(win), "The popup is not open."); + + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "The Urlbar no longer has the supress-focus-border attribute." + ); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that we show the focus border when new tabs are opened. +add_task(async function newTab() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Tabs opened with withNewTab don't focus the Urlbar, so we have to open one + // manually. + let tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => win.gURLBar.hasAttribute("focused"), + "Waiting for the Urlbar to become focused." + ); + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); +}); + +// Tests that we show the focus border when a new tab is opened and the address +// bar panel is already open. +add_task(async function newTab_alreadyOpen() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + await withAwaitProvider( + { results: [TEST_RESULT], priority: Infinity }, + getSuppressFocusPromise(win), + async () => { + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("l", { accelKey: true }, win); + }); + + let tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => !UrlbarTestUtils.isPopupOpen(win), + "Waiting for the Urlbar panel to close." + ); + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + BrowserTestUtils.removeTab(tab); + } + ); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function searchTip() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Set a pref to show a search tip button."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.searchTips.test.ignoreShowLimits", true]], + }); + + info("Open new tab."); + const tab = await openAboutNewTab(win); + + info("Click the tip button."); + const result = await UrlbarTestUtils.getDetailsOfResultAt(win, 0); + const button = result.element.row._buttons.get("0"); + await UrlbarTestUtils.promisePopupClose(win, () => { + EventUtils.synthesizeMouseAtCenter(button, {}, win); + }); + + Assert.ok( + !win.gURLBar.hasAttribute( + "suppress-focus-border", + "The Urlbar does not have the suppress-focus-border attribute." + ) + ); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function interactionOnNewTab() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Open about:newtab in new tab"); + const tab = await openAboutNewTab(win); + await BrowserTestUtils.waitForCondition( + () => win.gBrowser.selectedTab === tab + ); + + await testInteractionsOnAboutNewTab(win); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function interactionOnNewTabInPrivateWindow() { + const win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + await testInteractionsOnAboutNewTab(win); + await BrowserTestUtils.closeWindow(win); + await SimpleTest.promiseFocus(window); +}); + +add_task(async function clickOnEdgeOfURLBar() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + win.gURLBar.blur(); + + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "URLBar does not have suppress-focus-border attribute" + ); + + const onHiddenFocusRemoved = BrowserTestUtils.waitForCondition( + () => !win.gURLBar._hideFocus + ); + + const container = win.document.getElementById("urlbar-input-container"); + container.click(); + + await onHiddenFocusRemoved; + Assert.ok( + win.gURLBar.hasAttribute("suppress-focus-border"), + "suppress-focus-border is set from the beginning" + ); + + await UrlbarTestUtils.promisePopupClose(win.window); + await BrowserTestUtils.closeWindow(win); +}); + +async function testInteractionsOnAboutNewTab(win) { + info("Test for clicking on URLBar while showing about:newtab"); + await testInteractionFeature(() => { + info("Click on URLBar"); + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + }, win); + + info("Test for typing on .fake-editable while showing about:newtab"); + await testInteractionFeature(() => { + info("Type a character on .fake-editable"); + EventUtils.synthesizeKey("v", {}, win); + }, win); + Assert.equal(win.gURLBar.value, "v", "URLBar value is correct"); + + info("Test for typing on .fake-editable while showing about:newtab"); + await testInteractionFeature(() => { + info("Paste some words on .fake-editable"); + SpecialPowers.clipboardCopyString("paste test"); + win.document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); + SpecialPowers.clipboardCopyString(""); + }, win); + Assert.equal(win.gURLBar.value, "paste test", "URLBar value is correct"); +} + +async function testInteractionFeature(interaction, win) { + info("Focus on URLBar"); + win.gURLBar.value = ""; + win.gURLBar.focus(); + Assert.ok( + !win.gURLBar.hasAttribute("suppress-focus-border"), + "URLBar does not have suppress-focus-border attribute" + ); + + info("Click on search-handoff-button in newtab page"); + await ContentTask.spawn(win.gBrowser.selectedBrowser, null, async () => { + await ContentTaskUtils.waitForCondition(() => + content.document.querySelector(".search-handoff-button") + ); + content.document.querySelector(".search-handoff-button").click(); + }); + + await BrowserTestUtils.waitForCondition( + () => win.gURLBar._hideFocus, + "Wait until _hideFocus will be true" + ); + + const onHiddenFocusRemoved = BrowserTestUtils.waitForCondition( + () => !win.gURLBar._hideFocus + ); + + await interaction(); + + await onHiddenFocusRemoved; + Assert.ok( + win.gURLBar.hasAttribute("suppress-focus-border"), + "suppress-focus-border is set from the beginning" + ); + + const result = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 0); + Assert.ok(result, "The provider returned a result"); + await UrlbarTestUtils.promisePopupClose(win); +} + +function getSuppressFocusPromise(win = window) { + return new Promise(resolve => { + let observer = new MutationObserver(() => { + if ( + win.gURLBar.hasAttribute("suppress-focus-border") && + !UrlbarTestUtils.isPopupOpen(win) + ) { + resolve(); + observer.disconnect(); + } + }); + observer.observe(win.gURLBar.textbox, { + attributes: true, + attributeFilter: ["suppress-focus-border"], + }); + }); +} + +async function withAwaitProvider(args, promise, callback) { + let provider = new AwaitPromiseProvider(args, promise); + UrlbarProvidersManager.registerProvider(provider); + try { + await callback(); + } catch (ex) { + console.error(ex); + } finally { + UrlbarProvidersManager.unregisterProvider(provider); + } +} + +async function openAboutNewTab(win = window) { + // We have to listen for the new tab using this brute force method. + // about:newtab is preloaded in the background. When about:newtab is opened, + // the cached version is shown. Since the page is already loaded, + // waitForNewTab does not detect it. It also doesn't fire the TabOpen event. + const tabCount = win.gBrowser.tabs.length; + EventUtils.synthesizeKey("t", { accelKey: true }, win); + await TestUtils.waitForCondition( + () => win.gBrowser.tabs.length === tabCount + 1, + "Waiting for background about:newtab to open." + ); + return win.gBrowser.tabs[win.gBrowser.tabs.length - 1]; +} diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js b/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js new file mode 100644 index 0000000000..a9b0eb7b1a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * Checks that switching tabs closes the urlbar popup. + */ + +"use strict"; + +add_task(async function () { + let tab1 = BrowserTestUtils.addTab(gBrowser); + let tab2 = BrowserTestUtils.addTab(gBrowser); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + }); + + // Add a couple of dummy entries to ensure the history popup will open. + await PlacesTestUtils.addVisits([ + { uri: makeURI("http://example.com/foo") }, + { uri: makeURI("http://example.com/foo/bar") }, + ]); + + // When urlbar in a new tab is focused, and a tab switch occurs, + // the urlbar popup should be closed + await BrowserTestUtils.switchTab(gBrowser, tab2); + gURLBar.focus(); // focus the urlbar in the tab we will switch to + await BrowserTestUtils.switchTab(gBrowser, tab1); + // Now open the popup. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + // Check that the popup closes when we switch tab. + await UrlbarTestUtils.promisePopupClose(window, () => { + return BrowserTestUtils.switchTab(gBrowser, tab2); + }); + Assert.ok(true, "Popup was successfully closed"); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js b/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js new file mode 100644 index 0000000000..eccee800e3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_currentTab.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that switch to tab still works when the URI contains an + * encoded part. + */ + +"use strict"; + +add_task(async function test_switchTab_currentTab() { + registerCleanupFunction(PlacesUtils.history.clear); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots#1" }, + async () => { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots#2" }, + async () => { + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "robot", + }); + Assert.ok( + context.results.some( + result => + result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && + result.payload.url == "about:robots#1" + ) + ); + Assert.ok( + !context.results.some( + result => + result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && + result.payload.url == "about:robots#2" + ) + ); + } + ); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js b/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js new file mode 100644 index 0000000000..fe23eceaf9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_decodeuri.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that switch to tab still works when the URI contains an + * encoded part. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html#test%7C1`; + +add_task(async function test_switchtab_decodeuri() { + info("Opening first tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + info("Opening and selecting second tab"); + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy_page", + }); + + info("Select autocomplete popup entry"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("switch-to-tab"); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + window, + "TabSelect", + false + ); + EventUtils.synthesizeKey("KEY_Enter"); + await tabSelectPromise; + + Assert.equal( + gBrowser.selectedTab, + tab, + "Should have switched to the right tab" + ); + + gBrowser.removeCurrentTab(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js b/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js new file mode 100644 index 0000000000..0da3161d0e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_inputHistory.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests ensures that the urlbar adaptive behavior updates + * when using switch to tab in the address bar dropdown. + */ + +"use strict"; + +add_setup(async function () { + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_adaptive_with_search_term_and_switch_tab() { + await PlacesUtils.history.clear(); + let urls = [ + "https://example.com/", + "https://example.com/#cat", + "https://example.com/#cake", + "https://example.com/#car", + ]; + + info(`Load tabs in same order as urls`); + let tabs = []; + for (let url of urls) { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url, false, true); + gBrowser.loadTabs([url], { + inBackground: true, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + let tab = await tabPromise; + tabs.push(tab); + } + + info(`Switch to tab 0`); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ca", + }); + + let result1 = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.notEqual(result1.url, urls[1], `${urls[1]} url should not be first`); + + info(`Scroll down to select the ${urls[1]} entry using keyboard`); + let result2 = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + + while (result2.url != urls[1]) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + result2 = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + } + + Assert.equal( + result2.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Selected entry should be tab switch" + ); + Assert.equal(result2.url, urls[1]); + + info("Visiting tab 1"); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(gBrowser.selectedTab, tabs[1], "Should have switched to tab 1"); + + info("Switch back to tab 0"); + await BrowserTestUtils.switchTab(gBrowser, tabs[0]); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ca", + }); + + let result3 = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result3.url, urls[1], `${urls[1]} url should be first`); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchTab_override.js b/browser/components/urlbar/tests/browser/browser_switchTab_override.js new file mode 100644 index 0000000000..dabce43612 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchTab_override.js @@ -0,0 +1,100 @@ +/* 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/. */ + +/** + * This test ensures that overriding switch-to-tab correctly loads the page + * rather than switching to it. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function test_switchtab_override() { + info("Opening first tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + info("Opening and selecting second tab"); + let secondTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => { + try { + gBrowser.removeTab(tab); + gBrowser.removeTab(secondTab); + } catch (ex) { + /* tabs may have already been closed in case of failure */ + } + }); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy_page", + }); + + info("Select second autocomplete popup entry"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + let result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + // Check to see if the switchtab label is visible and + // all other labels are hidden + const allLabels = document.getElementById("urlbar-label-box").children; + for (let label of allLabels) { + if (label.id == "urlbar-label-switchtab") { + Assert.ok(BrowserTestUtils.is_visible(label)); + } else { + Assert.ok(BrowserTestUtils.is_hidden(label)); + } + } + + info("Override switch-to-tab"); + let deferred = PromiseUtils.defer(); + // In case of failure this would switch tab. + let onTabSelect = event => { + deferred.reject(new Error("Should have overridden switch to tab")); + }; + gBrowser.tabContainer.addEventListener("TabSelect", onTabSelect); + registerCleanupFunction(() => { + gBrowser.tabContainer.removeEventListener("TabSelect", onTabSelect); + }); + // Otherwise it would load the page. + BrowserTestUtils.browserLoaded(secondTab.linkedBrowser).then( + deferred.resolve + ); + + EventUtils.synthesizeKey("KEY_Shift", { type: "keydown" }); + + // Checks that all labels are hidden when Shift is held down on the SwitchToTab result + for (let label of allLabels) { + Assert.ok(BrowserTestUtils.is_hidden(label)); + } + + registerCleanupFunction(() => { + // Avoid confusing next tests by leaving a pending keydown. + EventUtils.synthesizeKey("KEY_Shift", { type: "keyup" }); + }); + + let attribute = "actionoverride"; + Assert.ok( + gURLBar.view.panel.hasAttribute(attribute), + "We should be overriding" + ); + + EventUtils.synthesizeKey("KEY_Enter"); + info(`gURLBar.value = ${gURLBar.value}`); + await deferred.promise; + + // Blurring the urlbar should have cleared the override. + Assert.ok( + !gURLBar.view.panel.hasAttribute(attribute), + "We should not be overriding anymore" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); + gBrowser.removeTab(secondTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js b/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js new file mode 100644 index 0000000000..1a0d2eef70 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js @@ -0,0 +1,217 @@ +/* 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/. */ + +add_task(async function test_ignoreFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home#1" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + let numTabsAtStart = gBrowser.tabs.length; + + switchTab("about:home#1", true); + switchTab("about:mozilla", true); + + let hashChangePromise = ContentTask.spawn( + tabRefAboutHome.linkedBrowser, + [], + async function () { + await ContentTaskUtils.waitForEvent(this, "hashchange", true); + } + ); + switchTab("about:home#2", true, { + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "The same about:home tab should be switched to" + ); + await hashChangePromise; + is(gBrowser.currentURI.ref, "2", "The ref should be updated to the new ref"); + switchTab("about:mozilla", true); + switchTab("about:home#3", true, { ignoreFragment: "whenComparing" }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "The same about:home tab should be switched to" + ); + is( + gBrowser.currentURI.ref, + "2", + "The ref should be unchanged since the fragment is only ignored when comparing" + ); + switchTab("about:mozilla", true); + switchTab("about:home#1", false); + isnot( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should not be initial about:blank tab" + ); + is( + gBrowser.tabs.length, + numTabsAtStart + 1, + "Should have one new tab opened" + ); + switchTab("about:mozilla", true); + switchTab("about:home", true, { ignoreFragment: "whenComparingAndReplace" }); + await BrowserTestUtils.waitForCondition(function () { + return tabRefAboutHome.linkedBrowser.currentURI.spec == "about:home"; + }); + is( + tabRefAboutHome.linkedBrowser.currentURI.spec, + "about:home", + "about:home shouldn't have hash" + ); + switchTab("about:about", false, { + ignoreFragment: "whenComparingAndReplace", + }); + cleanupTestTabs(); +}); + +add_task(async function test_ignoreQueryString() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + + switchTab("about:home?hello=firefox", true); + switchTab("about:home?hello=firefoxos", false); + // Remove the last opened tab to test ignoreQueryString option. + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos", true, { ignoreQueryString: true }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + is( + gBrowser.currentURI.spec, + "about:home?hello=firefox", + "The spec should NOT be updated to the new query string" + ); + cleanupTestTabs(); +}); + +add_task(async function test_replaceQueryString() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox" + ); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla"); + + switchTab("about:home", false); + switchTab("about:home?hello=firefox", true); + switchTab("about:home?hello=firefoxos", false); + // Remove the last opened tab to test replaceQueryString option. + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos", true, { replaceQueryString: true }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + // Wait for the tab to load the new URI spec. + await BrowserTestUtils.browserLoaded(tabRefAboutHome.linkedBrowser); + is( + gBrowser.currentURI.spec, + "about:home?hello=firefoxos", + "The spec should be updated to the new spec" + ); + cleanupTestTabs(); +}); + +add_task(async function test_replaceQueryStringAndFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox#aaa" + ); + let tabRefAboutMozilla = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla?hello=firefoxos#aaa" + ); + + switchTab("about:home", false); + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefox#aaa", true); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + switchTab("about:mozilla?hello=firefox#bbb", true, { + replaceQueryString: true, + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutMozilla, + gBrowser.selectedTab, + "Selected tab should be the initial about:mozilla tab" + ); + switchTab("about:home?hello=firefoxos#bbb", true, { + ignoreQueryString: true, + ignoreFragment: "whenComparingAndReplace", + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + cleanupTestTabs(); +}); + +add_task(async function test_ignoreQueryStringIgnoresFragment() { + let tabRefAboutHome = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:home?hello=firefox#aaa" + ); + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla?hello=firefoxos#aaa" + ); + + switchTab("about:home?hello=firefox#bbb", false, { ignoreQueryString: true }); + gBrowser.removeCurrentTab(); + switchTab("about:home?hello=firefoxos#aaa", true, { + ignoreQueryString: true, + }); + is( + tabRefAboutHome, + gBrowser.selectedTab, + "Selected tab should be the initial about:home tab" + ); + cleanupTestTabs(); +}); + +// Begin helpers + +function cleanupTestTabs() { + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +} + +function switchTab(aURI, aShouldFindExistingTab, aOpenParams = {}) { + // Build the description before switchToTabHavingURI deletes the object properties. + let msg = + `Should switch to existing ${aURI} tab if one existed, ` + + `${ + aOpenParams.ignoreFragment ? "ignoring" : "including" + } fragment portion, `; + if (aOpenParams.replaceQueryString) { + msg += "replacing"; + } else if (aOpenParams.ignoreQueryString) { + msg += "ignoring"; + } else { + msg += "including"; + } + msg += " query string."; + aOpenParams.triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + let tabFound = switchToTabHavingURI(aURI, true, aOpenParams); + is(tabFound, aShouldFindExistingTab, msg); +} + +registerCleanupFunction(cleanupTestTabs); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js b/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js new file mode 100644 index 0000000000..6702ce340a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_chiclet.js @@ -0,0 +1,110 @@ +/* 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 chiclet upon switching tab mode. + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function test_with_oneoff_button() { + info("Loading test page into first tab"); + await BrowserTestUtils.loadURIString(gBrowser, TEST_URL); + + info("Opening a new tab"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Wait for autocomplete"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + + info("Enter Tabs mode"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + + info("Select first popup entry"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("Enter escape key"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("Check label visibility"); + const searchModeTitle = document.getElementById( + "urlbar-search-mode-indicator-title" + ); + const switchTabLabel = document.getElementById("urlbar-label-switchtab"); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.is_visible(searchModeTitle) && + searchModeTitle.textContent === "Tabs", + "Waiting until the search mode title will be visible" + ); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.is_hidden(switchTabLabel), + "Waiting until the switch tab label will be hidden" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); +}); + +add_task(async function test_with_keytype() { + info("Loading test page into first tab"); + await BrowserTestUtils.loadURIString(gBrowser, TEST_URL); + + info("Opening a new tab"); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Enter Tabs mode with keytype"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "%", + }); + + info("Select second popup entry"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + const result = await UrlbarTestUtils.getDetailsOfResultAt( + window, + UrlbarTestUtils.getSelectedRowIndex(window) + ); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + + info("Enter escape key"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("Check label visibility"); + const searchModeTitle = document.getElementById( + "urlbar-search-mode-indicator-title" + ); + const switchTabLabel = document.getElementById("urlbar-label-switchtab"); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.is_hidden(searchModeTitle), + "Waiting until the search mode title will be hidden" + ); + await BrowserTestUtils.waitForCondition( + () => BrowserTestUtils.is_visible(switchTabLabel), + "Waiting until the switch tab label will be visible" + ); + + await PlacesUtils.history.clear(); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js b/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js new file mode 100644 index 0000000000..5031491d7e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_closes_newtab.js @@ -0,0 +1,63 @@ +/* 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/. */ + +/** + * This tests that switch to tab from a blank tab switches and then closes + * the blank tab. + */ + +"use strict"; + +add_task(async function test_switchToTab_closes() { + let testURL = + "http://example.org/browser/browser/components/urlbar/tests/browser/dummy_page.html"; + + // Open the base tab + let baseTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testURL); + + if (baseTab.linkedBrowser.currentURI.spec == "about:blank") { + return; + } + + // Open a blank tab to start the test from. + let testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Functions for TabClose and TabSelect + let tabClosePromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabClose", + false, + event => { + return event.originalTarget == testTab; + } + ); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabSelect", + false, + event => { + return event.originalTarget == baseTab; + } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "dummy", + }); + + // The first result is the heuristic, the second will be the switch to tab. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + EventUtils.synthesizeMouseAtCenter(element, {}, window); + + await Promise.all([tabSelectPromise, tabClosePromise]); + + // Confirm that the selected tab is now the base tab + Assert.equal( + gBrowser.selectedTab, + baseTab, + "Should have switched to the correct tab" + ); + + gBrowser.removeTab(baseTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js b/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js new file mode 100644 index 0000000000..8f80ac5841 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTab_fullUrl_repeatedKeydown.js @@ -0,0 +1,60 @@ +/* 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/. */ + +/** + * This tests that typing a url and picking a switch to tab actually switches + * to the right tab. Also tests repeated keydown/keyup events don't confuse + * override. + */ + +"use strict"; + +add_task(async function test_switchToTab_url() { + const TEST_URL = "https://example.org/browser/"; + + let baseTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + let testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Functions for TabClose and TabSelect + let tabClosePromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabClose", + false, + event => event.target == testTab + ); + let tabSelectPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabSelect", + false, + event => event.target == baseTab + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_URL, + fireInputEvent: true, + }); + // The first result is the heuristic, the second will be the switch to tab. + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + + // Simulate a long press, on some platforms (Windows) it can generate multiple + // keydown events. + EventUtils.synthesizeKey("VK_SHIFT", { type: "keydown", repeat: 3 }); + EventUtils.synthesizeKey("VK_SHIFT", { type: "keyup" }); + + // Pick the switch to tab result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + + await Promise.all([tabSelectPromise, tabClosePromise]); + + // Confirm that the selected tab is now the base tab + Assert.equal( + gBrowser.selectedTab, + baseTab, + "Should have switched to the correct tab" + ); + + gBrowser.removeTab(baseTab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js b/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js new file mode 100644 index 0000000000..e326939581 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js @@ -0,0 +1,378 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test makes sure that the tab key properly adjusts the selection or moves +// through toolbar items, depending on the urlbar state. +// When the view is open, tab should go through results if the urlbar was +// focused with the mouse, or has a typed string. + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.quickactions", false]], + }); + + for (let i = 0; i < UrlbarPrefs.get("maxRichResults"); i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + + registerCleanupFunction(PlacesUtils.history.clear); + + CustomizableUI.addWidgetToArea("home-button", "nav-bar", 0); + CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar"); + registerCleanupFunction(() => { + CustomizableUI.removeWidgetFromArea("home-button"); + CustomizableUI.removeWidgetFromArea("sidebar-button"); + }); +}); + +add_task(async function tabWithSearchString() { + info("Tab with a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await expectTabThroughResults(); + info("Reverse Tab with a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await expectTabThroughResults({ reverse: true }); +}); + +add_task(async function tabNoSearchString() { + info("Tab without a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await expectTabThroughToolbar(); + info("Reverse Tab without a search string"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await expectTabThroughToolbar({ reverse: true }); +}); + +add_task(async function tabAfterBlur() { + info("Tab after closing the view"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await expectTabThroughToolbar(); +}); + +add_task(async function tabNoSearchStringMouseFocus() { + info("Tab in a new blank tab after mouse focus"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); + }); + info("Tab in a loaded tab after mouse focus"); + await BrowserTestUtils.withNewTab("example.com", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); + }); +}); + +add_task(async function tabNoSearchStringKeyboardFocus() { + info("Tab in a new blank tab after keyboard focus"); + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughToolbar(); + }); + info("Tab in a loaded tab after keyboard focus"); + await BrowserTestUtils.withNewTab("example.com", async () => { + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughToolbar(); + }); +}); + +add_task(async function tabRetainedResultMouseFocus() { + info("Tab after retained results with mouse focus"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); +}); + +add_task(async function tabRetainedResultsKeyboardFocus() { + info("Tab after retained results with keyboard focus"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + await expectTabThroughResults(); +}); + +add_task(async function tabRetainedResults() { + info("Tab with a search string after mouse focus."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + await expectTabThroughResults(); +}); + +add_task(async function tabSearchModePreview() { + info( + "Tab past a search mode preview keywordoffer after focusing with the keyboard." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok( + result.searchParams.keyword, + "The first result is a keyword offer." + ); + + // Sanity check: the Urlbar value is cleared when keywordoffer results are + // selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok(!gURLBar.value, "The Urlbar should have no value."); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + await expectTabThroughResults(); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + gURLBar.blur(); + // Verify that blur closes search mode preview. + await UrlbarTestUtils.assertSearchMode(window, null); + }); +}); + +add_task(async function tabTabToSearch() { + info("Tab past a tab-to-search result after focusing with the keyboard."); + await SearchTestUtils.installSearchExtension(); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(["https://example.com/"]); + } + + // Search for a tab-to-search result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exam", + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + await expectTabThroughResults(); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + gURLBar.blur(); + await UrlbarTestUtils.assertSearchMode(window, null); + }); + await PlacesUtils.history.clear(); +}); + +add_task(async function tabNoSearchStringSearchMode() { + info( + "Tab through the toolbar when refocusing a Urlbar in search mode with the keyboard." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + // Enter history search mode to avoid hitting the network. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + await UrlbarTestUtils.promisePopupClose(window); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + await expectTabThroughToolbar(); + + // We have to reopen the view to exit search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +add_task(async function tabOnTopSites() { + info("Tab through the toolbar when focusing the Address Bar on top sites."); + for (let val of [true, false]) { + info(`Test with keyboard_navigation set to "${val}"`); + await SpecialPowers.pushPrefEnv({ + set: [["browser.toolbars.keyboard_navigation", val]], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + fireInputEvent: true, + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + "There should be some results" + ); + Assert.deepEqual( + UrlbarTestUtils.getSelectedElement(window), + null, + "There should be no selection" + ); + + await expectTabThroughToolbar(); + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); + } +}); + +async function expectTabThroughResults(options = { reverse: false }) { + let resultCount = UrlbarTestUtils.getResultCount(window); + Assert.ok(resultCount > 0, "There should be results"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + let initiallySelectedIndex = result.heuristic ? 0 : -1; + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + initiallySelectedIndex, + "Check the initial selection." + ); + + for (let i = initiallySelectedIndex + 1; i < resultCount; i++) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + if ( + UrlbarTestUtils.getButtonForResultIndex( + window, + "menu", + UrlbarTestUtils.getSelectedRowIndex(window) + ) + ) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + options.reverse ? resultCount - i : i + ); + } + + EventUtils.synthesizeKey("KEY_Tab"); + + if (!options.reverse) { + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + initiallySelectedIndex, + "Should be back at the initial selection." + ); + } + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +} + +async function expectTabThroughToolbar(options = { reverse: false }) { + if (gURLBar.getAttribute("pageproxystate") == "valid") { + Assert.equal(document.activeElement, gURLBar.inputField); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.notEqual(document.activeElement, gURLBar.inputField); + } else { + let focusPromise = waitForFocusOnNextFocusableElement(options.reverse); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: options.reverse }); + await focusPromise; + } + Assert.ok(!gURLBar.view.isOpen, "The urlbar view should be closed."); +} + +async function waitForFocusOnNextFocusableElement(reverse = false) { + if ( + !Services.prefs.getBoolPref("browser.toolbars.keyboard_navigation", true) + ) { + return BrowserTestUtils.waitForCondition( + () => document.activeElement == gBrowser.selectedBrowser + ); + } + let urlbar = document.getElementById("urlbar-container"); + let nextFocusableElement = reverse + ? urlbar.previousElementSibling + : urlbar.nextElementSibling; + while ( + nextFocusableElement && + (!nextFocusableElement.classList.contains("toolbarbutton-1") || + nextFocusableElement.hasAttribute("hidden") || + nextFocusableElement.hasAttribute("disabled") || + BrowserTestUtils.is_hidden(nextFocusableElement)) + ) { + nextFocusableElement = reverse + ? nextFocusableElement.previousElementSibling + : nextFocusableElement.nextElementSibling; + } + info( + `Next focusable element: ${nextFocusableElement.localName}.#${nextFocusableElement.id}` + ); + + Assert.ok( + nextFocusableElement.classList.contains("toolbarbutton-1"), + "We should have a reference to the next focusable element after the Urlbar." + ); + + return BrowserTestUtils.waitForCondition( + () => nextFocusableElement.tabIndex == -1 + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js new file mode 100644 index 0000000000..e186681907 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar.js @@ -0,0 +1,224 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +"use strict"; + +/** + * Tests for ensuring that the tab switch results correctly match what is + * currently available. + */ + +requestLongerTimeout(2); + +const TEST_URL_BASES = [ + `${TEST_BASE_URL}dummy_page.html#tabmatch`, + `${TEST_BASE_URL}moz.png#tabmatch`, +]; + +const RESTRICT_TOKEN_OPENPAGE = "%"; + +var gTabCounter = 0; + +add_task(async function step_1() { + info("Running step 1"); + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let promises = []; + for (let i = 0; i < maxResults - 1; i++) { + let tab = BrowserTestUtils.addTab(gBrowser); + promises.push(loadTab(tab, TEST_URL_BASES[0] + ++gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_2() { + info("Running step 2"); + gBrowser.selectTabAtIndex(1); + gBrowser.removeCurrentTab(); + gBrowser.selectTabAtIndex(1); + gBrowser.removeCurrentTab(); + gBrowser.selectTabAtIndex(0); + + let promises = []; + for (let i = 1; i < gBrowser.tabs.length; i++) { + promises.push(loadTab(gBrowser.tabs[i], TEST_URL_BASES[1] + ++gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_3() { + info("Running step 3"); + let promises = []; + for (let i = 1; i < gBrowser.tabs.length; i++) { + promises.push(loadTab(gBrowser.tabs[i], TEST_URL_BASES[0] + gTabCounter)); + } + + await Promise.all(promises); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_4() { + info("Running step 4 - ensure we don't register subframes as open pages"); + let tab = BrowserTestUtils.addTab( + gBrowser, + 'data:text/html,<body><iframe src=""></iframe></body>' + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let iframe_loaded = ContentTaskUtils.waitForEvent( + content.document, + "load", + true + ); + content.document.querySelector("iframe").src = "http://test2.example.org/"; + await iframe_loaded; + }); + + await ensure_opentabs_match_db(); +}); + +add_task(async function step_5() { + info("Running step 5 - remove tab immediately"); + let tab = BrowserTestUtils.addTab(gBrowser, "about:logo"); + BrowserTestUtils.removeTab(tab); + await ensure_opentabs_match_db(); +}); + +add_task(async function step_6() { + info( + "Running step 6 - check swapBrowsersAndCloseOther preserves registered switch-to-tab result" + ); + let tabToKeep = BrowserTestUtils.addTab(gBrowser); + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + gBrowser.updateBrowserRemoteness(tabToKeep.linkedBrowser, { + remoteType: tab.linkedBrowser.isRemoteBrowser + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE, + }); + gBrowser.swapBrowsersAndCloseOther(tabToKeep, tab); + + await ensure_opentabs_match_db(); + + BrowserTestUtils.removeTab(tabToKeep); + + await ensure_opentabs_match_db(); +}); + +add_task(async function step_7() { + info("Running step 7 - close all tabs"); + + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); + + BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }); + while (gBrowser.tabs.length > 1) { + info("Removing tab: " + gBrowser.tabs[0].linkedBrowser.currentURI.spec); + gBrowser.selectTabAtIndex(0); + gBrowser.removeCurrentTab(); + } + + await ensure_opentabs_match_db(); +}); + +add_task(async function cleanup() { + info("Cleaning up"); + + await PlacesUtils.history.clear(); +}); + +function loadTab(tab, url) { + // Because adding visits is async, we will not be notified immediately. + let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let visited = new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + if (url != aSubject.QueryInterface(Ci.nsIURI).spec) { + return; + } + Services.obs.removeObserver(observer, aTopic); + resolve(); + }, "uri-visit-saved"); + }); + + info("Loading page: " + url); + BrowserTestUtils.loadURIString(tab.linkedBrowser, url); + return Promise.all([loaded, visited]); +} + +function ensure_opentabs_match_db() { + let tabs = {}; + + for (let browserWin of Services.wm.getEnumerator("navigator:browser")) { + // skip closed-but-not-destroyed windows + if (browserWin.closed) { + continue; + } + + for (let i = 0; i < browserWin.gBrowser.tabs.length; i++) { + let browser = browserWin.gBrowser.getBrowserAtIndex(i); + let url = browser.currentURI.spec; + if (browserWin.isBlankPageURL(url)) { + continue; + } + if (!(url in tabs)) { + tabs[url] = 1; + } else { + tabs[url]++; + } + } + } + + return checkAutocompleteResults(tabs); +} + +async function checkAutocompleteResults(expected) { + info("Searching open pages."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: RESTRICT_TOKEN_OPENPAGE, + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.heuristic) { + info("Skip heuristic match"); + continue; + } + + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Should have a tab switch result" + ); + + let url = result.url; + + info(`Search for ${url} in open tabs.`); + let inExpected = url in expected; + Assert.ok( + inExpected, + `${url} was found in autocomplete, was ${ + inExpected ? "" : "not " + } expected` + ); + // Remove the found entry from expected results. + delete expected[url]; + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Make sure there is no reported open page that is not open. + for (let entry in expected) { + Assert.ok(!entry, `Should have been found in autocomplete`); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js new file mode 100644 index 0000000000..b7b13eecf8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we don't switch between tabs from normal window to + * private browsing window or opposite. + */ + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function () { + let normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(normalWindow, privateWindow, false); + await BrowserTestUtils.closeWindow(normalWindow); + await BrowserTestUtils.closeWindow(privateWindow); + + normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(privateWindow, normalWindow, false); + await BrowserTestUtils.closeWindow(normalWindow); + await BrowserTestUtils.closeWindow(privateWindow); + + privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await runTest(privateWindow, privateWindow, true); + await BrowserTestUtils.closeWindow(privateWindow); + + normalWindow = await BrowserTestUtils.openNewBrowserWindow(); + await runTest(normalWindow, normalWindow, true); + await BrowserTestUtils.closeWindow(normalWindow); +}); + +async function runTest(aSourceWindow, aDestWindow, aExpectSwitch, aCallback) { + await BrowserTestUtils.openNewForegroundTab(aSourceWindow.gBrowser, TEST_URL); + let testTab = await BrowserTestUtils.openNewForegroundTab( + aDestWindow.gBrowser + ); + + info("waiting for focus on the window"); + await SimpleTest.promiseFocus(aDestWindow); + info("got focus on the window"); + + // Select the testTab + aDestWindow.gBrowser.selectedTab = testTab; + + // Ensure that this tab has no history entries + let sessionHistoryCount = await new Promise(resolve => { + SessionStore.getSessionHistory( + gBrowser.selectedTab, + function (sessionHistory) { + resolve(sessionHistory.entries.length); + } + ); + }); + + ok( + sessionHistoryCount < 2, + `The test tab has 1 or fewer history entries. sessionHistoryCount=${sessionHistoryCount}` + ); + // Ensure that this tab is on about:blank + is( + testTab.linkedBrowser.currentURI.spec, + "about:blank", + "The test tab is on about:blank" + ); + // Ensure that this tab's document has no child nodes + await SpecialPowers.spawn(testTab.linkedBrowser, [], async function () { + ok( + !content.document.body.hasChildNodes(), + "The test tab has no child nodes" + ); + }); + ok( + !testTab.hasAttribute("busy"), + "The test tab doesn't have the busy attribute" + ); + + // Wait for the Awesomebar popup to appear. + let searchString = TEST_URL; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: aDestWindow, + value: searchString, + }); + + info(`awesomebar popup appeared. aExpectSwitch: ${aExpectSwitch}`); + // Make sure the last match is selected. + while ( + UrlbarTestUtils.getSelectedRowIndex(aDestWindow) < + UrlbarTestUtils.getResultCount(aDestWindow) - 1 + ) { + info("handling key navigation for DOM_VK_DOWN key"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, aDestWindow); + } + + let awaitTabSwitch; + if (aExpectSwitch) { + awaitTabSwitch = BrowserTestUtils.waitForTabClosing(testTab); + } + + // Execute the selected action. + EventUtils.synthesizeKey("KEY_Enter", {}, aDestWindow); + info("sent Enter command to the controller"); + + if (aExpectSwitch) { + // If we expect a tab switch then the current tab + // will be closed and we switch to the other tab. + await awaitTabSwitch; + } else { + // If we don't expect a tab switch then wait for the tab to load. + await BrowserTestUtils.browserLoaded(testTab.linkedBrowser); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_tabToSearch.js b/browser/components/urlbar/tests/browser/browser_tabToSearch.js new file mode 100644 index 0000000000..b029682eda --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabToSearch.js @@ -0,0 +1,641 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests tab-to-search results. See also + * browser/components/urlbar/tests/unit/test_providerTabToSearch.js. + */ + +"use strict"; + +const TEST_ENGINE_NAME = "Test"; +const TEST_ENGINE_DOMAIN = "example.com"; + +const DYNAMIC_RESULT_TYPE = "onboardTabToSearch"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +add_setup(async function () { + await PlacesUtils.history.clear(); + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable onboarding results for general tests. They are enabled in tests + // that specifically address onboarding. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + search_url: `https://${TEST_ENGINE_DOMAIN}/`, + }); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${TEST_ENGINE_DOMAIN}/`]); + } + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests that tab-to-search results preview search mode when highlighted. These +// results are worth testing separately since they do not set the +// payload.keyword parameter. +add_task(async function basic() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let autofillResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(autofillResult.autofill); + Assert.equal( + autofillResult.url, + `https://${TEST_ENGINE_DOMAIN}/`, + "The autofilled URL matches the engine domain." + ); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + TEST_ENGINE_NAME, + "The tab-to-search result is for the correct engine." + ); + let tabToSearchDetails = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + let [actionTabToSearch] = await document.l10n.formatValues([ + { + id: Services.search.getEngineByName( + tabToSearchDetails.searchParams.engine + ).isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { engine: tabToSearchDetails.searchParams.engine }, + }, + ]); + Assert.equal( + tabToSearchDetails.displayed.title, + `Search with ${tabToSearchDetails.searchParams.engine}`, + "The result's title is set correctly." + ); + Assert.equal( + tabToSearchDetails.displayed.action, + actionTabToSearch, + "The correct action text is displayed in the tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Tests that we do not set aria-activedescendant after tabbing to a +// tab-to-search result when the pref +// browser.urlbar.accessibility.tabToSearch.announceResults is true. If that +// pref is true, the result was already announced while the user was typing. +add_task(async function activedescendant_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.accessibility.tabToSearch.announceResults", true]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "There should be two results." + ); + let tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + let aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal(aadID, null, "aria-activedescendant was not set."); + + // Cycle through all the results then return to the tab-to-search result. It + // should be announced. + EventUtils.synthesizeKey("KEY_Tab"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + let firstRow = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + aadID, + firstRow._content.id, + "aria-activedescendant was set to the row after the tab-to-search result." + ); + EventUtils.synthesizeKey("KEY_Tab"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + // Now close and reopen the view, then do another search that yields a + // tab-to-search result. aria-activedescendant should not be set when it is + // selected. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal(aadID, null, "aria-activedescendant was not set."); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we set aria-activedescendant after accessing a tab-to-search +// result with the arrow keys. +add_task(async function activedescendant_arrow() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchRow = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + tabToSearchRow.result.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + let aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + // Move selection away from the tab-to-search result then return. It should + // be announced. + EventUtils.synthesizeKey("KEY_ArrowDown"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.id, + "aria-activedescendant was moved to the first one-off." + ); + EventUtils.synthesizeKey("KEY_ArrowUp"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow._content.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +add_task(async function tab_key_race() { + // Mac Debug tinderboxes are just too slow and fail intermittently + // even if the EventBufferer timeout is set to an high value. + if (AppConstants.platform == "macosx" && AppConstants.DEBUG) { + return; + } + info( + "Test typing a letter followed shortly by down arrow consistently selects a tab-to-search result" + ); + Assert.equal(gURLBar.value, "", "Sanity check urlbar is empty"); + let promiseQueryStarted = new Promise(resolve => { + /** + * A no-op test provider. + * We use this to wait for the query to start, because otherwise TAB will + * move to the next widget since the panel is closed and there's no running + * query. This means waiting for the UrlbarProvidersManager to at least + * evaluate the isActive status of providers. + * In the future we should try to reduce this latency, to defer user events + * even more efficiently. + */ + class ListeningTestProvider extends UrlbarProvider { + constructor() { + super(); + } + get name() { + return "ListeningTestProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + executeSoon(resolve); + return false; + } + isRestricting(context) { + return false; + } + async startQuery(context, addCallback) { + // Nothing to do. + } + } + let provider = new ListeningTestProvider(); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(async function () { + UrlbarProvidersManager.unregisterProvider(provider); + }); + }); + gURLBar.focus(); + info("Type the beginning of the search string to get tab-to-search"); + EventUtils.synthesizeKey(TEST_ENGINE_DOMAIN.slice(0, 1)); + info("Awaiting for the query to start"); + await promiseQueryStarted; + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promiseSearchComplete(window); + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getSelectedRowIndex(window) == 1, + "Wait for down arrow key to be handled" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// Test that large-style onboarding results appear and have the correct +// properties. +add_task(async function onboard() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let autofillResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(autofillResult.autofill); + Assert.equal( + autofillResult.url, + `https://${TEST_ENGINE_DOMAIN}/`, + "The autofilled URL matches the engine domain." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + + // Now check the properties of the onboarding result. + let onboardingElement = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 1 + ); + Assert.equal( + onboardingElement.result.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The tab-to-search result is an onboarding result." + ); + Assert.equal( + onboardingElement.result.resultSpan, + 2, + "The correct resultSpan was set." + ); + Assert.ok( + onboardingElement + .querySelector(".urlbarView-row-inner") + .hasAttribute("selected"), + "The onboarding element set the selected attribute." + ); + + let [titleOnboarding, actionOnboarding, descriptionOnboarding] = + await document.l10n.formatValues([ + { + id: "urlbar-result-action-search-w-engine", + args: { + engine: onboardingElement.result.payload.engine, + }, + }, + { + id: Services.search.getEngineByName( + onboardingElement.result.payload.engine + ).isGeneralPurposeEngine + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { engine: onboardingElement.result.payload.engine }, + }, + { + id: "urlbar-tabtosearch-onboard", + }, + ]); + let onboardingDetails = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + onboardingDetails.displayed.title, + titleOnboarding, + "The correct title was set." + ); + Assert.equal( + onboardingDetails.displayed.action, + actionOnboarding, + "The correct action text was set." + ); + Assert.equal( + onboardingDetails.element.row.querySelector( + ".urlbarView-dynamic-onboardTabToSearch-description" + ).textContent, + descriptionOnboarding, + "The correct description was set." + ); + + // Check that the onboarding result enters search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + }); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we show the onboarding result until the user interacts with it +// `browser.urlbar.tabToSearch.onboard.interactionsLeft` times. +add_task(async function onboard_limit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 3, + "Sanity check: interactionsLeft is 3." + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let onboardingResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal(UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), 2); + await UrlbarTestUtils.exitSearchMode(window); + + // We don't increment the counter if we showed the onboarding result less than + // 5 minutes ago. + for (let i = 0; i < 5; i++) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + onboardingResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 2, + "We shouldn't decrement interactionsLeft if an onboarding result was just shown." + ); + await UrlbarTestUtils.exitSearchMode(window); + } + + // If the user doesn't interact with the result, we don't increment the + // counter. + for (let i = 0; i < 5; i++) { + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The tab-to-search result is an onboarding result." + ); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + 2, + "We shouldn't decrement interactionsLeft if the user doesn't interact with onboarding." + ); + } + + // Test that we increment the counter if the user interacts with the result + // and it's been 5+ minutes since they last interacted with it. + for (let i = 1; i >= 0; i--) { + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + onboardingResult.payload.dynamicType, + DYNAMIC_RESULT_TYPE, + "The second result is an onboarding result." + ); + await EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch_onboard", + isPreview: true, + }); + Assert.equal( + UrlbarPrefs.get("tabToSearch.onboard.interactionsLeft"), + i, + "We decremented interactionsLeft." + ); + await UrlbarTestUtils.exitSearchMode(window); + } + + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.notEqual( + tabToSearchResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "Now that interactionsLeft is 0, we don't show onboarding results." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); + +// Tests that we show at most one onboarding result at a time. See +// tests/unit/test_providerTabToSearch.js:multipleEnginesForHostname for a test +// that ensures only one normal tab-to-search result is shown in this scenario. +add_task(async function onboard_multipleEnginesForHostname() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + let extension = await SearchTestUtils.installSearchExtension( + { + name: `${TEST_ENGINE_NAME}Maps`, + search_url: `https://${TEST_ENGINE_DOMAIN}/maps/`, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Only two results are shown." + ); + let firstResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0) + ).result; + Assert.notEqual( + firstResult.providerName, + "TabToSearch", + "The first result is not from TabToSearch." + ); + let secondResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + secondResult.providerName, + "TabToSearch", + "The second result is from TabToSearch." + ); + Assert.equal( + secondResult.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "The tab-to-search result is the only onboarding result." + ); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + await extension.unload(); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_textruns.js b/browser/components/urlbar/tests/browser/browser_textruns.js new file mode 100644 index 0000000000..ed7a61e6b0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_textruns.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we limit textruns in case of very long urls or titles. + */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + await SearchTestUtils.installSearchExtension( + { name: "Test" }, + { setAsDefault: true } + ); + + let lotsOfSpaces = "%20".repeat(300); + await PlacesTestUtils.addVisits({ + uri: `https://textruns.mozilla.org/${lotsOfSpaces}/test/`, + title: `A long ${lotsOfSpaces} title`, + }); + await UrlbarTestUtils.formHistory.add([ + { value: `A long ${lotsOfSpaces} textruns suggestion` }, + ]); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "textruns", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.searchParams.engine, "Test", "Sanity check engine"); + Assert.equal( + result.displayed.title.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result title should be limited" + ); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 2); + Assert.equal( + result.displayed.title.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result title should be limited" + ); + Assert.equal( + result.displayed.url.length, + UrlbarUtils.MAX_TEXT_LENGTH, + "Result url should be limited" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_tokenAlias.js b/browser/components/urlbar/tests/browser/browser_tokenAlias.js new file mode 100644 index 0000000000..d215c2536f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tokenAlias.js @@ -0,0 +1,861 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test checks "@" search engine aliases ("token aliases") in the urlbar. + +"use strict"; + +const TEST_ALIAS_ENGINE_NAME = "Test"; +const ALIAS = "@test"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +// Allow more time for Mac machines so they don't time out in verify mode. See +// bug 1673062. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(5); +} + +add_setup(async function () { + // Add a default engine with suggestions, to avoid hitting the network when + // fetching them. + let defaultEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + defaultEngine.alias = "@default"; + await SearchTestUtils.installSearchExtension({ + name: TEST_ALIAS_ENGINE_NAME, + keyword: ALIAS, + }); + + // Search results aren't shown in quantumbar unless search suggestions are + // enabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); +}); + +// Simple test that tries different variations of an alias, without reverting +// the urlbar value in between. +add_task(async function testNoRevert() { + await doSimpleTest(false); +}); + +// Simple test that tries different variations of an alias, reverting the urlbar +// value in between. +add_task(async function testRevert() { + await doSimpleTest(true); +}); + +async function doSimpleTest(revertBetweenSteps) { + // When autofill is enabled, searching for "@tes" will autofill to "@test", + // which gets in the way of this test task, so temporarily disable it. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + // "@tes" -- not an alias, no search mode + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.substr(0, ALIAS.length - 1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.substr(0, ALIAS.length - 1), + "value should be alias substring" + ); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@test" -- alias but no trailing space, no search mode + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal(gURLBar.value, ALIAS, "value should be alias"); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@test " -- alias with trailing space, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test foo" -- alias, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces + "foo", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "foo", "value should be query"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test " -- alias with trailing space, search mode + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + } + + // "@test" -- alias but no trailing space, no highlight + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS, + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal(gURLBar.value, ALIAS, "value should be alias"); + + if (revertBetweenSteps) { + gURLBar.handleRevert(); + gURLBar.blur(); + } + + // "@tes" -- not an alias, no highlight + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS.substr(0, ALIAS.length - 1), + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + ALIAS.substr(0, ALIAS.length - 1), + "value should be alias substring" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + await SpecialPowers.popPrefEnv(); +} + +// An alias should be recognized even when there are spaces before it, and +// search mode should be entered. +add_task(async function spacesBeforeAlias() { + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: spaces + ALIAS + spaces, + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + } +}); + +// An alias in the middle of a string should not be recognized and search mode +// should not be entered. +add_task(async function charsBeforeAlias() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "not an alias " + ALIAS + " ", + }); + await UrlbarTestUtils.assertSearchMode(window, null); + Assert.equal( + gURLBar.value, + "not an alias " + ALIAS + " ", + "value should be unchanged" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// While already in search mode, an alias should not be recognized. +add_task(async function alreadyInSearchMode() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ALIAS + " ", + }); + + // Search mode source should still be bookmarks. + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "oneoff", + }); + Assert.equal(gURLBar.value, ALIAS + " ", "value should be unchanged"); + + // Exit search mode, but first remove the value in the input. Since the value + // is "alias ", we'd actually immediately re-enter search mode otherwise. + gURLBar.value = ""; + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Types a space while typing an alias to ensure we stop autofilling. +add_task(async function spaceWhileTypingAlias() { + for (let spaces of TEST_SPACES) { + if (spaces.length != 1) { + continue; + } + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + + let value = ALIAS.substring(0, ALIAS.length - 1); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + Assert.equal(gURLBar.value, ALIAS + " ", "Alias should be autofilled"); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(spaces); + await searchPromise; + + Assert.equal( + gURLBar.value, + value + spaces, + "Alias should not be autofilled" + ); + await UrlbarTestUtils.assertSearchMode(window, null); + + await UrlbarTestUtils.promisePopupClose(window); + } +}); + +// Aliases are case insensitive. Make sure that searching with an alias using a +// weird case still causes the alias to be recognized and search mode entered. +add_task(async function aliasCase() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@TeSt ", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Same as previous but with a query. +add_task(async function aliasCase_query() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@tEsT query", + }); + // Wait for the second new search that starts when search mode is entered. + await UrlbarTestUtils.promiseSearchComplete(window); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "query", "value should be query"); + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Selecting a non-heuristic (non-first) search engine result with an alias and +// empty query should put the alias in the urlbar and highlight it. +// Also checks that internal aliases appear with the "@" keyword. +add_task(async function nonHeuristicAliases() { + // Get the list of token alias engines (those with aliases that start with + // "@"). + let tokenEngines = []; + for (let engine of await Services.search.getEngines()) { + let aliases = []; + if (engine.alias) { + aliases.push(engine.alias); + } + aliases.push(...engine.aliases); + let tokenAliases = aliases.filter(a => a.startsWith("@")); + if (tokenAliases.length) { + tokenEngines.push({ engine, tokenAliases }); + } + } + if (!tokenEngines.length) { + Assert.ok(true, "No token alias engines, skipping task."); + return; + } + info( + "Got token alias engines: " + tokenEngines.map(({ engine }) => engine.name) + ); + + // Populate the results with the list of token alias engines by searching for + // "@". + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + tokenEngines.length - 1 + ); + // Key down to select each result in turn. The urlbar should preview search + // mode for each engine. + for (let { tokenAliases } of tokenEngines) { + let alias = tokenAliases[0]; + let engineName = (await UrlbarSearchUtils.engineForAlias(alias)).name; + EventUtils.synthesizeKey("KEY_ArrowDown"); + let expectedSearchMode = { + engineName, + entry: "keywordoffer", + isPreview: true, + }; + if (Services.search.getEngineByName(engineName).isGeneralPurposeEngine) { + expectedSearchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + await UrlbarTestUtils.assertSearchMode(window, expectedSearchMode); + Assert.ok(!gURLBar.value, "The Urlbar should be empty."); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Clicking on an @ alias offer (an @ alias with an empty search string) in the +// view should enter search mode. +add_task(async function clickAndFillAlias() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let testEngineItem; + for (let i = 0; !testEngineItem; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + details.displayed.title, + `Search with ${details.searchParams.engine}`, + "The result's title is set correctly." + ); + Assert.ok(!details.action, "The result should have no action text."); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + testEngineItem = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + } + } + + // Click it. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(testEngineItem, {}); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: testEngineItem.result.payload.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing enter on an @ alias offer (an @ alias with an empty search string) +// in the view should enter search mode. +add_task(async function enterAndFillAlias() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let details; + let index = 0; + for (; ; index++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + index++; + break; + } + } + + // Key down to it and press enter. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: index }); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: details.searchParams.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Enter on an @ alias autofill should enter search mode. +add_task(async function enterAutofillsAlias() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + // Press Enter. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.exitSearchMode(window); + } + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Right on an @ alias autofill should enter search mode. +add_task(async function rightEntersSearchMode() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowRight"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "typed", + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + await UrlbarTestUtils.exitSearchMode(window); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +// Pressing Tab when an @ alias is autofilled should enter search mode preview. +add_task(async function rightEntersSearchMode() { + for (let value of [ALIAS.substring(0, ALIAS.length - 1), ALIAS]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + selectionStart: value.length, + selectionEnd: value.length, + }); + + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "There is no selected result." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The first result is selected." + ); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + isPreview: true, + }); + Assert.equal(gURLBar.value, "", "value should be empty"); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ALIAS_ENGINE_NAME, + entry: "keywordoffer", + isPreview: false, + }); + await UrlbarTestUtils.exitSearchMode(window); + } + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); +}); + +/** + * This test checks that if an engine is marked as hidden then + * it should not appear in the popup when using the "@" token alias in the search bar. + */ +add_task(async function hiddenEngine() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + + const defaultEngine = await Services.search.getDefault(); + + let foundDefaultEngineInPopup = false; + + // Checks that the default engine appears in the urlbar's popup. + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (defaultEngine.name == details.searchParams.engine) { + foundDefaultEngineInPopup = true; + break; + } + } + Assert.ok(foundDefaultEngineInPopup, "Default engine appears in the popup."); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + // Checks that a hidden default engine (i.e. an engine removed from + // a user's search settings) does not appear in the urlbar's popup. + defaultEngine.hidden = true; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + foundDefaultEngineInPopup = false; + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (defaultEngine.name == details.searchParams.engine) { + foundDefaultEngineInPopup = true; + break; + } + } + Assert.ok( + !foundDefaultEngineInPopup, + "Hidden default engine does not appear in the popup" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Escape") + ); + + defaultEngine.hidden = false; +}); + +/** + * This test checks that if an engines alias is not prefixed with + * @ it still appears in the popup when using the "@" token + * alias in the search bar. + */ +add_task(async function nonPrefixedKeyword() { + let name = "Custom"; + let alias = "customkeyword"; + let extension = await SearchTestUtils.installSearchExtension( + { + name, + keyword: alias, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + let foundEngineInPopup = false; + + // Checks that the default engine appears in the urlbar's popup. + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (details.searchParams.engine === name) { + foundEngineInPopup = true; + break; + } + } + Assert.ok(foundEngineInPopup, "Custom engine appears in the popup."); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@" + alias, + }); + + let keywordOfferResult = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + + Assert.equal( + keywordOfferResult.searchParams.engine, + name, + "The first result should be a keyword search result with the correct engine." + ); + + await extension.unload(); +}); + +// Tests that we show all engines with a token alias that match the search +// string. +add_task(async function multipleMatchingEngines() { + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestFoo", + keyword: `${ALIAS}foo`, + }, + { skipUnload: true } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@te", + fireInputEvent: true, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Two results are shown." + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Neither result is selected." + ); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(result.autofill, "The first result is autofilling."); + Assert.equal( + result.searchParams.keyword, + ALIAS, + "The autofilled engine is shown first." + ); + + result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + result.searchParams.keyword, + `${ALIAS}foo`, + "The other engine is shown second." + ); + + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 0); + Assert.equal(gURLBar.value, "", "Urlbar should be empty."); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), 1); + Assert.equal(gURLBar.value, "", "Urlbar should be empty."); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + -1, + "Tabbing all the way through the matching engines should return to the input." + ); + Assert.equal( + gURLBar.value, + "@te", + "Urlbar should contain the search string." + ); + + await extension.unload(); +}); + +// Tests that UrlbarProviderTokenAliasEngines is disabled in search mode. +add_task(async function doNotShowInSearchMode() { + // Do a search for "@" to show all the @ aliases. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + }); + + // Find our test engine in the results. It's probably last, but for + // robustness don't assume it is. + let testEngineItem; + for (let i = 0; !testEngineItem; i++) { + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (details.searchParams && details.searchParams.keyword == ALIAS) { + testEngineItem = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + } + } + + Assert.equal( + testEngineItem.result.payload.keyword, + ALIAS, + "Sanity check: we found our engine." + ); + + // Click it. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(testEngineItem, {}); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: testEngineItem.result.payload.engine, + entry: "keywordoffer", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@", + fireInputEvent: true, + }); + + let resultCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + !result.searchParams.keyword, + `Result at index ${i} is not a keywordoffer.` + ); + } +}); + +async function assertFirstResultIsAlias(isAlias, expectedAlias) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Should have the correct type" + ); + + if (isAlias) { + Assert.equal( + result.searchParams.keyword, + expectedAlias, + "Payload keyword should be the alias" + ); + } else { + Assert.notEqual( + result.searchParams.keyword, + expectedAlias, + "Payload keyword should be absent or not the alias" + ); + } +} + +function assertHighlighted(highlighted, expectedAlias) { + let selection = gURLBar.editor.selectionController.getSelection( + Ci.nsISelectionController.SELECTION_FIND + ); + Assert.ok(selection); + if (!highlighted) { + Assert.equal(selection.rangeCount, 0); + return; + } + Assert.equal(selection.rangeCount, 1); + let index = gURLBar.value.indexOf(expectedAlias); + Assert.ok( + index >= 0, + `gURLBar.value="${gURLBar.value}" expectedAlias="${expectedAlias}"` + ); + let range = selection.getRangeAt(0); + Assert.ok(range); + Assert.equal(range.startOffset, index); + Assert.equal(range.endOffset, index + expectedAlias.length); +} + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/browser/browser_top_sites.js b/browser/components/urlbar/tests/browser/browser_top_sites.js new file mode 100644 index 0000000000..cb6502d2b2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites.js @@ -0,0 +1,481 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +async function addTestVisits() { + // Add some visits to a URL. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + // Wait for example.com to be listed first. + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == "http://example.com/"; + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://www.youtube.com/", + title: "YouTube", + }); +} + +async function checkDoesNotOpenOnFocus(win = window) { + // The view should not open when the input is focused programmatically. + win.gURLBar.blur(); + win.gURLBar.focus(); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + // Check the keyboard shortcut. + win.document.getElementById("Browser:OpenLocation").doCommand(); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); + + // Focus with the mouse. + EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + // Because the panel opening may not be immediate, we must wait a bit. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.ok(!win.gURLBar.view.isOpen, "check urlbar panel is not open"); + win.gURLBar.blur(); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ], + }); + + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function topSitesShown() { + let sites = AboutNewTab.getTopSites(); + + for (let prefVal of [true, false]) { + // This test should work regardless of whether Top Sites are enabled on + // about:newtab. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.topsites", prefVal]], + }); + // We don't expect this to change, but we run updateTopSites just in case + // feeds.topsites were to have an effect on the composition of Top Sites. + await updateTopSites(siteList => siteList.length == 6); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + sites.length, + "The number of results should be the same as the number of Top Sites (6)." + ); + + for (let i = 0; i < sites.length; i++) { + let site = sites[i]; + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (site.searchTopSite) { + Assert.equal( + result.searchParams.keyword, + site.label, + "The search Top Site should have an alias." + ); + continue; + } + + Assert.equal( + site.url, + result.url, + "The Top Site URL and the result URL shoud match." + ); + Assert.equal( + site.label || site.title || site.hostname, + result.title, + "The Top Site title and the result title shoud match." + ); + } + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + // This pops updateTopSites changes. + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + } +}); + +add_task(async function selectSearchTopSite() { + await updateTopSites( + sites => sites && sites[0] && sites[0].searchTopSite, + true + ); + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + let amazonSearch = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 0 + ); + + Assert.equal( + amazonSearch.result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "First result should have SEARCH type." + ); + + Assert.equal( + amazonSearch.result.payload.keyword, + "@amazon", + "First result should have the Amazon keyword." + ); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(amazonSearch, {}); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(window, { + engineName: amazonSearch.result.payload.engine, + entry: "topsites_urlbar", + }); + await UrlbarTestUtils.exitSearchMode(window, { backspace: true }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +}); + +add_task(async function topSitesBookmarksAndTabs() { + await addTestVisits(); + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the first result." + ); + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The example.com Top Site should appear in the view as an open tab result." + ); + + let youtubeResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + youtubeResult.url, + "https://www.youtube.com/", + "The YouTube Top Site should be the second result." + ); + Assert.equal( + youtubeResult.source, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + "The YouTube Top Site should appear in the view as a bookmark result." + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesKeywordNavigationPageproxystate() { + await addTestVisits(); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Sanity check initial state" + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + let count = UrlbarTestUtils.getResultCount(window); + Assert.equal(count, 7, "The number of results should be the expected one."); + + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Moving through results" + ); + } + for (let i = 0; i < count; ++i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "invalid", + "Moving through results" + ); + } + + // Double ESC should restore state. + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + EventUtils.synthesizeKey("KEY_Escape"); + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Double ESC should restore state" + ); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesPinned() { + await addTestVisits(); + let info = { url: "http://example.com/" }; + NewTabUtils.pinnedLinks.pin(info, 0); + + await updateTopSites(sites => sites && sites[0] && sites[0].isPinned); + + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the first result." + ); + + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.TABS, + "The example.com Top Site should be an open tab result." + ); + + Assert.ok( + exampleResult.element.row.hasAttribute("pinned"), + "The example.com Top Site should have the pinned property." + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + NewTabUtils.pinnedLinks.unpin(info); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +}); + +add_task(async function topSitesBookmarksAndTabsDisabled() { + await addTestVisits(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.openpage", false], + ["browser.urlbar.suggest.bookmark", false], + ], + }); + + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + let exampleResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal( + exampleResult.url, + "http://example.com/", + "The example.com Top Site should be the second result." + ); + Assert.equal( + exampleResult.source, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + "The example.com Top Site should appear as a normal result even though it's open in a tab." + ); + + let youtubeResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal( + youtubeResult.url, + "https://www.youtube.com/", + "The YouTube Top Site should be the third result." + ); + Assert.equal( + youtubeResult.source, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + "The YouTube Top Site should appear as a normal result even though it's bookmarked." + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function topSitesDisabled() { + // Disable Top Sites feed. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.system.topsites", false]], + }); + await checkDoesNotOpenOnFocus(); + await SpecialPowers.popPrefEnv(); + + // Top Sites should also not be shown when Urlbar Top Sites are disabled. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.topsites", false]], + }); + await checkDoesNotOpenOnFocus(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function topSitesNumber() { + // Add some visits + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-a.com/", + "http://example-b.com/", + "http://example-c.com/", + "http://example-d.com/", + "http://example-e.com/", + ]); + } + + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites && sites.length == 8); + Assert.equal( + AboutNewTab.getTopSites().length, + 8, + "The test suite browser should have 8 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 8, + "The number of results should be the default (8)." + ); + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.topSitesRows", 2]], + }); + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites && sites.length == 11); + Assert.equal( + AboutNewTab.getTopSites().length, + 11, + "The test suite browser should have 11 Top Sites." + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 10, + "The number of results should be maxRichResults (10)." + ); + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.popPrefEnv(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_top_sites_private.js b/browser/components/urlbar/tests/browser/browser_top_sites_private.js new file mode 100644 index 0000000000..bcc6a70d88 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites_private.js @@ -0,0 +1,174 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +async function addTestVisits() { + // Add some visits to a URL. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("http://example.com/"); + } + + // Wait for example.com to be listed first. + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == "http://example.com/"; + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://www.youtube.com/", + title: "YouTube", + }); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.urlbar.suggest.quickactions", false], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ], + }); + + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +add_task(async function topSitesPrivateWindow() { + // Top Sites should also be shown in private windows. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await addTestVisits(); + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 7, + "The test suite browser should have 7 Top Sites." + ); + let urlbar = privateWin.gURLBar; + await UrlbarTestUtils.promisePopupOpen(privateWin, () => { + if (urlbar.getAttribute("pageproxystate") == "invalid") { + urlbar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(urlbar.inputField, {}, privateWin); + }); + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + + Assert.equal( + UrlbarTestUtils.getResultCount(privateWin), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + // Top sites should also be shown in a private window if the search string + // gets cleared. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: privateWin, + value: "example", + }); + urlbar.select(); + EventUtils.synthesizeKey("KEY_Backspace", {}, privateWin); + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + Assert.equal( + UrlbarTestUtils.getResultCount(privateWin), + 7, + "The number of results should be the same as the number of Top Sites (7)." + ); + + await BrowserTestUtils.closeWindow(privateWin); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +}); + +add_task(async function topSitesTabSwitch() { + // Add some visits + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(["http://example.com/"]); + } + + // Switch to the originating tab, to check for switch to the current tab. + gBrowser.selectedTab = gBrowser.tabs[0]; + + // Wait for the expected number of Top sites. + await updateTopSites(sites => sites?.length == 7); + Assert.equal( + AboutNewTab.getTopSites().length, + 7, + "The test suite browser should have 7 Top Sites." + ); + + async function checkResults(win, expectedResultType) { + let resultCount = UrlbarTestUtils.getResultCount(win); + let result; + for (let i = 0; i < resultCount; ++i) { + result = await UrlbarTestUtils.getDetailsOfResultAt(win, i); + if (result.url == "http://example.com/") { + break; + } + } + Assert.equal( + result.type, + expectedResultType, + `Should provide a result of type ${expectedResultType}.` + ); + } + + info("Test in a non-private window"); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + Assert.ok(gURLBar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(window); + await checkResults(window, UrlbarUtils.RESULT_TYPE.TAB_SWITCH); + await UrlbarTestUtils.promisePopupClose(window); + + info("Test in a private window, switch to tab should not be offered"); + // Top Sites should also be shown in private windows. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let urlbar = privateWin.gURLBar; + await UrlbarTestUtils.promisePopupOpen(privateWin, () => { + if (urlbar.getAttribute("pageproxystate") == "invalid") { + urlbar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(urlbar.inputField, {}, privateWin); + }); + + Assert.ok(urlbar.view.isOpen, "UrlbarView should be open."); + await UrlbarTestUtils.promiseSearchComplete(privateWin); + await checkResults(privateWin, UrlbarUtils.RESULT_TYPE.URL); + await UrlbarTestUtils.promisePopupClose(privateWin); + await BrowserTestUtils.closeWindow(privateWin); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_typed_value.js b/browser/components/urlbar/tests/browser/browser_typed_value.js new file mode 100644 index 0000000000..ca4566d172 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_typed_value.js @@ -0,0 +1,69 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that the urlbar is restored to the typed value on blur. + +"use strict"; + +add_setup(async function () { + registerCleanupFunction(async function () { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/foo", + ]); +}); + +add_task(async function test_autofill() { + let typed = "ex"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + fireInputEvent: true, + }); + Assert.equal(gURLBar.value, "example.com/", "autofilled value as expected"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, gURLBar.value.length); + + gURLBar.blur(); + Assert.equal(gURLBar.value, typed, "Value should have been restored"); + gURLBar.focus(); + Assert.equal(gURLBar.value, typed, "Value should not have changed"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, typed.length); +}); + +add_task(async function test_complete_selection() { + let typed = "ex"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: typed, + fireInputEvent: true, + }); + Assert.equal(gURLBar.value, "example.com/", "autofilled value as expected"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Should have the correct number of matches" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + gURLBar.value, + "example.com/foo", + "Value should have been completed" + ); + + gURLBar.blur(); + Assert.equal(gURLBar.value, typed, "Value should have been restored"); + gURLBar.focus(); + Assert.equal(gURLBar.value, typed, "Value should not have changed"); + Assert.equal(gURLBar.selectionStart, typed.length); + Assert.equal(gURLBar.selectionEnd, typed.length); +}); diff --git a/browser/components/urlbar/tests/browser/browser_unitConversion.js b/browser/components/urlbar/tests/browser/browser_unitConversion.js new file mode 100644 index 0000000000..566300b7d4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_unitConversion.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests unit conversion on browser. + */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + registerCleanupFunction(function () { + SpecialPowers.clipboardCopyString(""); + }); +}); + +add_task(async function test_selectByMouse() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Clear clipboard content. + SpecialPowers.clipboardCopyString(""); + + const row = await doUnitConversion(win); + + info("Check if the result is copied to clipboard when selecting by mouse"); + EventUtils.synthesizeMouseAtCenter( + row.querySelector(".urlbarView-no-wrap"), + {}, + win + ); + assertClipboard(); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_selectByKey() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // Clear clipboard content. + SpecialPowers.clipboardCopyString(""); + + await doUnitConversion(win); + + // As gURLBar might lost focus, + // give focus again in order to enable key event on the result. + win.gURLBar.focus(); + + info("Check if the result is copied to clipboard when selecting by key"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + assertClipboard(); + + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); +}); + +function assertClipboard() { + Assert.equal( + SpecialPowers.getClipboardData("text/plain"), + "100 cm", + "The result of conversion is copied to clipboard" + ); +} + +async function doUnitConversion(win) { + info("Do unit conversion then wait the result"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "1m to cm", + waitForFocus: SimpleTest.waitForFocus, + }); + + const row = await UrlbarTestUtils.waitForAutocompleteResultAt(win, 1); + + Assert.ok(row.querySelector(".urlbarView-favicon"), "The icon is displayed"); + Assert.equal( + row.querySelector(".urlbarView-dynamic-unitConversion-output").textContent, + "100 cm", + "The unit is converted" + ); + + return row; +} diff --git a/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js new file mode 100644 index 0000000000..4f34b5d52a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js @@ -0,0 +1,51 @@ +"use strict"; + +/** + * Disable keyword.enabled (so no keyword search), and check that when + * you type in "example" and hit enter, the browser shows an error page. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["keyword.enabled", false]], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function (browser) { + gURLBar.value = "example"; + gURLBar.select(); + const loadPromise = BrowserTestUtils.waitForErrorPage(browser); + EventUtils.sendKey("return"); + await loadPromise; + ok(true, "error page is loaded correctly"); + } + ); +}); + +/** + * Disable keyword.enabled (so no keyword search) and enable fixup.alternate, and check + * that when you type in "example" and hit enter, the browser loads and the URL bar + * is updated accordingly. + */ +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["keyword.enabled", false], + ["browser.fixup.alternate.enabled", true], + ], + }); + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function (browser) { + gURLBar.value = "example"; + gURLBar.select(); + const loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://www.example.com/", + gBrowser.selectedBrowser + ); + + EventUtils.sendKey("return"); + await loadPromise; + ok(true, "https://www.example.com is loaded correctly"); + } + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js b/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js new file mode 100644 index 0000000000..9f736ea6af --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_annotation.js @@ -0,0 +1,333 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether a visit information is annotated correctly when picking a result. + +if (AppConstants.platform === "macosx") { + requestLongerTimeout(2); +} + +const FRECENCY = { + ORGANIC: 2000, + SPONSORED: -1, + BOOKMARKED: 2075, + SEARCHED: 100, +}; + +const { + VISIT_SOURCE_ORGANIC, + VISIT_SOURCE_SPONSORED, + VISIT_SOURCE_BOOKMARKED, + VISIT_SOURCE_SEARCHED, +} = PlacesUtils.history; + +/** + * To be used before checking database contents when they depend on a visit + * being added to History. + * + * @param {string} href the page to await notifications for. + */ +async function waitForVisitNotification(href) { + await PlacesTestUtils.waitForNotification("page-visited", events => + events.some(e => e.url === href) + ); +} + +async function assertDatabase({ targetURL, expected }) { + const frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: targetURL } + ); + Assert.equal(frecency, expected.frecency, "Frecency is correct"); + + const placesId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + url: targetURL, + }); + const expectedTriggeringPlaceId = expected.triggerURL + ? await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + url: expected.triggerURL, + }) + : null; + const db = await PlacesUtils.promiseDBConnection(); + const rows = await db.execute( + "SELECT source, triggeringPlaceId FROM moz_historyvisits WHERE place_id = :place_id AND source = :source", + { + place_id: placesId, + source: expected.source, + } + ); + Assert.equal(rows.length, 1); + Assert.equal( + rows[0].getResultByName("triggeringPlaceId"), + expectedTriggeringPlaceId, + `The triggeringPlaceId in database is correct for ${targetURL}` + ); +} + +function registerProvider(payload) { + const provider = new UrlbarTestUtils.TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights([], { + ...payload, + }) + ), + ], + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(provider); + return provider; +} + +async function pickResult({ input, payloadURL, redirectTo }) { + const destinationURL = redirectTo || payloadURL; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + fireInputEvent: true, + }); + + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(result.url, payloadURL); + UrlbarTestUtils.setSelectedRowIndex(window, 0); + + info("Show result and wait for loading"); + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + destinationURL + ); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); +}); + +add_task(async function basic() { + const testData = [ + { + description: "Sponsored result", + input: "exa", + payload: { + url: "http://example.com/", + isSponsored: true, + }, + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.SPONSORED, + }, + }, + { + description: "Bookmarked result", + input: "exa", + payload: { + url: "http://example.com/", + }, + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("http://example.com/"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_BOOKMARKED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + { + description: "Sponsored and bookmarked result", + input: "exa", + payload: { + url: "http://example.com/", + isSponsored: true, + }, + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("http://example.com/"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + { + description: "Organic result", + input: "exa", + payload: { + url: "http://example.com/", + }, + expected: { + source: VISIT_SOURCE_ORGANIC, + frecency: FRECENCY.ORGANIC, + }, + }, + ]; + + for (const { description, input, payload, bookmarks, expected } of testData) { + info(description); + const provider = registerProvider(payload); + + for (const bookmark of bookmarks || []) { + await PlacesUtils.bookmarks.insert(bookmark); + } + + await BrowserTestUtils.withNewTab("about:blank", async () => { + info("Pick result"); + let promiseVisited = waitForVisitNotification(payload.url); + await pickResult({ input, payloadURL: payload.url }); + await promiseVisited; + info("Check database"); + await assertDatabase({ targetURL: payload.url, expected }); + }); + + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } +}); + +add_task(async function redirection() { + const redirectTo = "http://example.com/"; + const payload = { + url: "http://example.com/browser/browser/components/urlbar/tests/browser/redirect_to.sjs?/", + isSponsored: true, + }; + const input = "exa"; + const provider = registerProvider(payload); + + await BrowserTestUtils.withNewTab("about:home", async () => { + info("Pick result"); + let promises = [ + waitForVisitNotification(payload.url), + waitForVisitNotification(redirectTo), + ]; + await pickResult({ input, payloadURL: payload.url, redirectTo }); + await Promise.all(promises); + + info("Check database"); + await assertDatabase({ + targetURL: payload.url, + expected: { + source: VISIT_SOURCE_SPONSORED, + frecency: FRECENCY.SPONSORED, + }, + }); + await assertDatabase({ + targetURL: redirectTo, + expected: { + source: VISIT_SOURCE_SPONSORED, + triggerURL: payload.url, + frecency: FRECENCY.SPONSORED, + }, + }); + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function search() { + const originalDefaultEngine = await Services.search.getDefault(); + await SearchTestUtils.installSearchExtension({ + name: "test engine", + keyword: "@test", + }); + + const testData = [ + { + description: "Searched result", + input: "@test abc", + resultURL: "https://example.com/?q=abc", + expected: { + source: VISIT_SOURCE_SEARCHED, + frecency: FRECENCY.SEARCHED, + }, + }, + { + description: "Searched bookmarked result", + input: "@test abc", + resultURL: "https://example.com/?q=abc", + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("https://example.com/?q=abc"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_BOOKMARKED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + ]; + + for (const { + description, + input, + resultURL, + bookmarks, + expected, + } of testData) { + info(description); + await BrowserTestUtils.withNewTab("about:blank", async () => { + for (const bookmark of bookmarks || []) { + await PlacesUtils.bookmarks.insert(bookmark); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + resultURL + ); + let promiseVisited = waitForVisitNotification(resultURL); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + await promiseVisited; + await assertDatabase({ targetURL: resultURL, expected }); + + // Open another URL to check whther the source is not inherited. + const payload = { url: "http://example.com/" }; + const provider = registerProvider(payload); + promiseVisited = waitForVisitNotification(payload.url); + await pickResult({ input, payloadURL: payload.url }); + await promiseVisited; + await assertDatabase({ + targetURL: payload.url, + expected: { + source: VISIT_SOURCE_ORGANIC, + frecency: FRECENCY.ORGANIC, + }, + }); + UrlbarProvidersManager.unregisterProvider(provider); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + }); + } + + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_abandonment.js b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_abandonment.js new file mode 100644 index 0000000000..6f30392e48 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_abandonment.js @@ -0,0 +1,357 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const TEST_ENGINE_NAME = "Test"; +const TEST_ENGINE_ALIAS = "@test"; +const TEST_ENGINE_DOMAIN = "example.com"; + +// Each test is a function that executes an urlbar action and returns the +// expected event object. +const tests = [ + async function (win) { + info("Type something, blur."); + win.gURLBar.select(); + EventUtils.synthesizeKey("x", {}, win); + win.gURLBar.blur(); + return { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + }, + }; + }, + + async function (win) { + info("Open the panel with DOWN, don't type, blur it."); + await addTopSite("http://example.org/"); + win.gURLBar.value = ""; + win.gURLBar.select(); + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + win.gURLBar.blur(); + return { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + }, + }; + }, + + async function (win) { + info("With pageproxystate=valid, autoopen the panel, don't type, blur it."); + win.gURLBar.value = ""; + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + win.gURLBar.blur(); + return { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + }, + }; + }, + + async function (win) { + info("Enter search mode from Top Sites."); + await updateTopSites(sites => true, /* enableSearchShorcuts */ true); + + win.gURLBar.value = ""; + win.gURLBar.select(); + + await BrowserTestUtils.waitForCondition(async () => { + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + + if (UrlbarTestUtils.getResultCount(win) > 1) { + return true; + } + + win.gURLBar.view.close(); + return false; + }); + + while (win.gURLBar.searchMode?.engineName != "Google") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + + let element = UrlbarTestUtils.getSelectedRow(win); + Assert.ok( + element.result.source == UrlbarUtils.RESULT_SOURCE.SEARCH, + "The selected result is a search Top Site." + ); + + let engine = element.result.payload.engine; + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: engine, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "topsites_urlbar", + }); + + await UrlbarTestUtils.exitSearchMode(win); + + // To avoid needing to add a custom search shortcut Top Site, we just + // abandon this interaction. + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + + return [ + // engagement on the top sites search engine to enter search mode + { + category: "urlbar", + method: "engagement", + object: "click", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selIndex: "0", + selType: "searchengine", + provider: "UrlbarProviderTopSites", + }, + }, + // abandonment + { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + }, + }, + ]; + }, + + async function (win) { + info("Open search mode from a tab-to-search result."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await PlacesUtils.history.clear(); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${TEST_ENGINE_DOMAIN}/`]); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + }); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(win, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + // Select the tab-to-search result. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + }); + + // Abandon the interaction since simply entering search mode is not + // considered the end of an engagement. + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); + + return [ + // engagement on the tab-to-search to enter search mode + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "4", + numWords: "1", + selIndex: "1", + selType: "tabtosearch", + provider: "TabToSearch", + }, + }, + // abandonment + { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + }, + }, + ]; + }, + + async function (win) { + info( + "With pageproxystate=invalid, open retained results, don't type, blur it." + ); + win.gURLBar.value = "mochi.test"; + win.gURLBar.setPageProxyState("invalid"); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + win.gURLBar.blur(); + return { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "returned", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "10", + numWords: "1", + }, + }; + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + + // Create a new search engine and mark it as default + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: TEST_ENGINE_ALIAS, + search_url: `https://${TEST_ENGINE_DOMAIN}/`, + }); + + // This test used to rely on the initial timer of + // TestUtils.waitForCondition. See bug 1667216. + let originalWaitForCondition = TestUtils.waitForCondition; + TestUtils.waitForCondition = async function ( + condition, + msg, + interval = 100, + maxTries = 50 + ) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 100)); + + return originalWaitForCondition(condition, msg, interval, maxTries); + }; + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + TestUtils.waitForCondition = originalWaitForCondition; + }); +}); + +async function doTest(eventTelemetryEnabled) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", eventTelemetryEnabled]], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // This is not necessary after each loop, because assertEvents does it. + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + + for (let i = 0; i < tests.length; i++) { + info(`Running test at index ${i}`); + let events = await tests[i](win); + if (!Array.isArray(events)) { + events = [events]; + } + // Always blur to ensure it's not accounted as an additional abandonment. + win.gURLBar.setSearchMode({}); + win.gURLBar.blur(); + TelemetryTestUtils.assertEvents(eventTelemetryEnabled ? events : [], { + category: "urlbar", + }); + + // Scalars should be recorded regardless of `eventTelemetry.enabled`. + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.engagement", + events.filter(e => e.method == "engagement").length || undefined + ); + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.abandonment", + events.filter(e => e.method == "abandonment").length || undefined + ); + + await UrlbarTestUtils.formHistory.clear(win); + } + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +} + +add_task(async function enabled() { + await doTest(true); +}); + +add_task(async function disabled() { + await doTest(false); +}); + +/** + * Replaces the contents of Top Sites with the specified site. + * + * @param {string} site + * A site to add to Top Sites. + */ +async function addTopSite(site) { + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(site); + } + + await updateTopSites(sites => sites && sites[0] && sites[0].url == site); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_engagement.js b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_engagement.js new file mode 100644 index 0000000000..c1fd36b452 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_engagement.js @@ -0,0 +1,1340 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const TEST_ENGINE_NAME = "Test"; +const TEST_ENGINE_ALIAS = "@test"; +const TEST_ENGINE_DOMAIN = "example.com"; + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +// Each test is a function that executes an urlbar action and returns the +// expected event object. +const tests = [ + async function (win) { + info("Type something, press Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Type a multi-word query, press Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "multi word query ", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "17", + numWords: "3", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Paste something, press Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await SimpleTest.promiseClipboardChange("test", () => { + clipboardHelper.copyString("test"); + }); + win.document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "pasted", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "4", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Type something, click one-off and press enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "test", + fireInputEvent: true, + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }, win); + let selectedOneOff = + UrlbarTestUtils.getOneOffSearchButtons(win).selectedButton; + selectedOneOff.click(); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: selectedOneOff.engine.name, + entry: "oneoff", + }); + + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "4", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info( + "Type something, select one-off with enter, and select result with enter." + ); + win.gURLBar.select(); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "test", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }, win); + let selectedOneOff = + UrlbarTestUtils.getOneOffSearchButtons(win).selectedButton; + Assert.ok(selectedOneOff); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await searchPromise; + + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "4", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Type something, ESC, type something else, press Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + EventUtils.synthesizeKey("x", {}, win); + EventUtils.synthesizeKey("VK_ESCAPE", {}, win); + EventUtils.synthesizeKey("y", {}, win); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Type a keyword, Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "kw test", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "7", + numWords: "2", + selIndex: "0", + selType: "keyword", + provider: "BookmarkKeywords", + }, + }; + }, + + async function (win) { + let tipProvider = registerTipProvider(); + info("Selecting a tip's main button, enter."); + win.gURLBar.search("x"); + await UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + unregisterTipProvider(tipProvider); + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "1", + selType: "tip", + provider: tipProvider.name, + }, + }; + }, + + async function (win) { + let tipProvider = registerTipProvider(); + info("Selecting a tip's help option."); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + win.gURLBar.search("x"); + await UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + if (UrlbarPrefs.get("resultMenu")) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(win, "h"); + } else { + EventUtils.synthesizeKey("KEY_Tab", {}, win); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + } + await promise; + unregisterTipProvider(tipProvider); + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "1", + selType: "tiphelp", + provider: tipProvider.name, + }, + }; + }, + + async function (win) { + info("Type something and canonize"); + win.gURLBar.select(); + const promise = BrowserTestUtils.waitForDocLoadAndStopIt( + "https://www.example.com/", + win.gBrowser.selectedBrowser + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "example", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", { ctrlKey: true }, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "7", + numWords: "1", + selIndex: "0", + selType: "canonized", + provider: "Autofill", + }, + }; + }, + + async function (win) { + info("Type something, click on bookmark entry."); + // Add a clean bookmark. + const bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/bookmark", + title: "bookmark", + }); + + win.gURLBar.select(); + let url = "http://example.com/bookmark"; + let promise = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "boo", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != url) { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + let element = UrlbarTestUtils.getSelectedRow(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await promise; + await PlacesUtils.bookmarks.remove(bookmark); + return { + category: "urlbar", + method: "engagement", + object: "click", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: val => parseInt(val) > 0, + selType: "bookmark", + provider: "Places", + }, + }; + }, + + async function (win) { + info("Type an autofilled string, Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "exa", + fireInputEvent: true, + }); + // Check it's autofilled. + Assert.equal(win.gURLBar.selectionStart, 3); + Assert.equal(win.gURLBar.selectionEnd, 12); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: "0", + selType: "autofill_origin", + provider: "Autofill", + }, + }; + }, + + async function (win) { + info("Type something, select bookmark entry, Enter."); + + // Add a clean bookmark and the input history in order to detect in InputHistory + // provider and to not show adaptive history autofill. + const bookmark = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/bookmark", + title: "bookmark", + }); + await UrlbarUtils.addToInputHistory( + "http://example.com/bookmark", + "bookmark" + ); + + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "boo", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != "http://example.com/bookmark") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + await PlacesUtils.bookmarks.remove(bookmark); + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: val => parseInt(val) > 0, + selType: "bookmark", + provider: "InputHistory", + }, + }; + }, + + async function (win) { + info("Type something, select remote search suggestion, Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "foo", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != "foofoo") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: val => parseInt(val) > 0, + selType: "searchsuggestion", + provider: "SearchSuggestions", + }, + }; + }, + + async function (win) { + info("Type something, select form history, Enter."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 2]], + }); + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "foo", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != "foofoo") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + await SpecialPowers.popPrefEnv(); + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: val => parseInt(val) > 0, + selType: "formhistory", + provider: "SearchSuggestions", + }, + }; + }, + + async function (win) { + info("Type @, enter on a keywordoffer, then search and press enter."); + win.gURLBar.select(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "@", + fireInputEvent: true, + }); + + while (win.gURLBar.searchMode?.engineName != TEST_ENGINE_NAME) { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "moz", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + + return [ + // engagement on the keyword offer result to enter search mode + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "6", + selType: "searchengine", + provider: "TokenAliasEngines", + }, + }, + // engagement on the search heuristic + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }, + ]; + }, + + async function (win) { + info("Type an @alias, then space, then search and press enter."); + const alias = "testalias"; + await SearchTestUtils.installSearchExtension({ + name: "AliasTest", + keyword: alias, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: `${alias} `, + }); + + await UrlbarTestUtils.assertSearchMode(win, { + engineName: "AliasTest", + entry: "typed", + }); + + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "moz", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Drop something."); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + EventUtils.synthesizeDrop( + win.document.getElementById("back-button"), + win.gURLBar.inputField, + [[{ type: "text/plain", data: "www.example.com" }]], + "copy", + win + ); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "drop_go", + value: "dropped", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "15", + numWords: "1", + selIndex: "-1", + selType: "none", + provider: "", + }, + }; + }, + + async function (win) { + info("Paste and Go something."); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await SimpleTest.promiseClipboardChange("www.example.com", () => { + clipboardHelper.copyString("www.example.com"); + }); + let inputBox = win.gURLBar.querySelector("moz-input-box"); + let cxmenu = inputBox.menupopup; + let cxmenuPromise = BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + win.gURLBar.inputField, + { + type: "contextmenu", + button: 2, + }, + win + ); + await cxmenuPromise; + let menuitem = inputBox.getMenuItem("paste-and-go"); + cxmenu.activateItem(menuitem); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "paste_go", + value: "pasted", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "15", + numWords: "1", + selIndex: "-1", + selType: "none", + provider: "", + }, + }; + }, + + // The URLs in the down arrow/autoOpen tests must vary from test to test, + // else the first Top Site results will be a switch-to-tab result and a page + // load will not occur. + async function (win) { + info("Open the panel with DOWN, select with DOWN, Enter."); + await addTopSite("http://example.org/"); + win.gURLBar.value = ""; + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + while (win.gURLBar.untrimmedValue != "http://example.org/") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selType: "history", + selIndex: val => parseInt(val) >= 0, + provider: "UrlbarProviderTopSites", + }, + }; + }, + + async function (win) { + info("Open the panel with DOWN, click on entry."); + await addTopSite("http://example.com/"); + win.gURLBar.value = ""; + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + while (win.gURLBar.untrimmedValue != "http://example.com/") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + let element = UrlbarTestUtils.getSelectedRow(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "click", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selType: "history", + selIndex: "0", + provider: "UrlbarProviderTopSites", + }, + }; + }, + + // The URLs in the autoOpen tests must vary from test to test, else + // the first Top Site results will be a switch-to-tab result and a page load + // will not occur. + async function (win) { + info( + "With pageproxystate=valid, autoopen the panel, select with DOWN, Enter." + ); + await addTopSite("http://example.org/"); + win.gURLBar.value = ""; + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + while (win.gURLBar.untrimmedValue != "http://example.org/") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selType: "history", + selIndex: val => parseInt(val) >= 0, + provider: "UrlbarProviderTopSites", + }, + }; + }, + + async function (win) { + info("With pageproxystate=valid, autoopen the panel, click on entry."); + await addTopSite("http://example.com/"); + win.gURLBar.value = ""; + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + while (win.gURLBar.untrimmedValue != "http://example.com/") { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + let element = UrlbarTestUtils.getSelectedRow(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "click", + value: "topsites", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selType: "history", + selIndex: "0", + provider: "UrlbarProviderTopSites", + }, + }; + }, + + async function (win) { + info("With pageproxystate=invalid, open retained results, Enter."); + await addTopSite("http://example.org/"); + win.gURLBar.value = "example.org"; + win.gURLBar.setPageProxyState("invalid"); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "returned", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "11", + numWords: "1", + selType: "autofill_origin", + selIndex: "0", + provider: "Autofill", + }, + }; + }, + + async function (win) { + info("With pageproxystate=invalid, open retained results, click on entry."); + // This value must be different from the previous test, to avoid reopening + // the view. + win.gURLBar.value = "example.com"; + win.gURLBar.setPageProxyState("invalid"); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + let element = UrlbarTestUtils.getSelectedRow(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "click", + value: "returned", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "11", + numWords: "1", + selType: "autofill_origin", + selIndex: "0", + provider: "Autofill", + }, + }; + }, + + async function (win) { + info("Reopen the view: type, blur, focus, confirm."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "search", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + await UrlbarTestUtils.promiseSearchComplete(win); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return [ + { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "6", + numWords: "1", + }, + }, + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "returned", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "6", + numWords: "1", + selType: "searchengine", + selIndex: "0", + provider: "HeuristicFallback", + }, + }, + ]; + }, + + async function (win) { + info("Open search mode with a keyboard shortcut."); + // Bug 1797801: If the search mode used is the same as the default engine and + // showSearchTerms is enabled, the chiclet will remain in the urlbar on the search. + // Subsequent tests rely on search mode not already been selected. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + let defaultEngine = await Services.search.getDefault(); + win.gURLBar.select(); + EventUtils.synthesizeKey("k", { accelKey: true }, win); + await UrlbarTestUtils.assertSearchMode(win, { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: defaultEngine.name, + entry: "shortcut", + }); + + // Execute a search to finish the engagement. + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "moz", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + + await SpecialPowers.popPrefEnv(); + + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Open search mode from a tab-to-search result."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await PlacesUtils.history.clear(); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${TEST_ENGINE_DOMAIN}/`]); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + }); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(win, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + + // Select the tab-to-search result. + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + }); + + // Execute a search to finish the engagement. + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "moz", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); + + return [ + // engagement on the tab-to-search to enter search mode + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "4", + numWords: "1", + selIndex: "1", + selType: "tabtosearch", + provider: "TabToSearch", + }, + }, + // engagement on the search heuristic + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "3", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }, + ]; + }, + + async function (win) { + info("Sanity check we are not stuck on 'returned'"); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, + + async function (win) { + info("Reopen the view: type, blur, focus, backspace, type, confirm."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "search", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + EventUtils.synthesizeKey("VK_RIGHT", {}, win); + EventUtils.synthesizeKey("VK_BACK_SPACE", {}, win); + EventUtils.synthesizeKey("x", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return [ + { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "6", + numWords: "1", + }, + }, + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "returned", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "6", + numWords: "1", + selType: "searchengine", + selIndex: "0", + provider: "HeuristicFallback", + }, + }, + ]; + }, + + async function (win) { + info("Reopen the view: type, blur, focus, type (overwrite), confirm."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "search", + fireInputEvent: true, + }); + await UrlbarTestUtils.promisePopupClose(win, () => { + win.gURLBar.blur(); + }); + await UrlbarTestUtils.promisePopupOpen(win, () => { + win.document.getElementById("Browser:OpenLocation").doCommand(); + }); + EventUtils.synthesizeKey("x", {}, win); + await UrlbarTestUtils.promiseSearchComplete(win); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return [ + { + category: "urlbar", + method: "abandonment", + object: "blur", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "6", + numWords: "1", + }, + }, + { + category: "urlbar", + method: "engagement", + object: "enter", + value: "restarted", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selType: "searchengine", + selIndex: "0", + provider: "HeuristicFallback", + }, + }, + ]; + }, + + async function (win) { + info("Sanity check we are not stuck on 'restarted'"); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + return { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "1", + numWords: "1", + selIndex: "0", + selType: "searchengine", + provider: "HeuristicFallback", + }, + }; + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Create a new search engine and mark it as default + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml", + setAsDefault: true, + }); + await Services.search.moveEngine(engine, 0); + + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: TEST_ENGINE_ALIAS, + search_url: `https://${TEST_ENGINE_DOMAIN}/`, + }); + + // Add a bookmark and a keyword. + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/?q=%s", + title: "test", + }); + await PlacesUtils.keywords.insert({ + keyword: "kw", + url: "http://example.com/?q=%s", + }); + + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("kw"); + await PlacesUtils.bookmarks.remove(bm); + await PlacesUtils.history.clear(); + }); +}); + +async function doTest(eventTelemetryEnabled) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.eventTelemetry.enabled", eventTelemetryEnabled], + ["browser.urlbar.suggest.searches", true], + ], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // This is not necessary after each loop, because assertEvents does it. + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + + for (let i = 0; i < tests.length; i++) { + info(`Running test at index ${i}`); + let events = await tests[i](win); + if (events === null) { + info("Skipping test"); + continue; + } + if (!Array.isArray(events)) { + events = [events]; + } + // Always blur to ensure it's not accounted as an additional abandonment. + win.gURLBar.setSearchMode({}); + win.gURLBar.blur(); + TelemetryTestUtils.assertEvents(eventTelemetryEnabled ? events : [], { + category: "urlbar", + }); + + // Scalars should be recorded regardless of `eventTelemetry.enabled`. + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.engagement", + events.filter(e => e.method == "engagement").length || undefined + ); + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.abandonment", + events.filter(e => e.method == "abandonment").length || undefined + ); + + await UrlbarTestUtils.formHistory.clear(win); + } + + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +} + +add_task(async function enabled() { + await doTest(true); +}); + +add_task(async function disabled() { + await doTest(false); +}); + +/** + * Replaces the contents of Top Sites with the specified site. + * + * @param {string} site + * A site to add to Top Sites. + */ +async function addTopSite(site) { + await PlacesUtils.history.clear(); + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(site); + } + + await updateTopSites(sites => sites && sites[0] && sites[0].url == site); +} + +function registerTipProvider() { + let provider = new UrlbarTestUtils.TestProvider({ + results: tipMatches, + priority: 1, + }); + UrlbarProvidersManager.registerProvider(provider); + return provider; +} + +function unregisterTipProvider(provider) { + UrlbarProvidersManager.unregisterProvider(provider); +} + +let tipMatches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "http://example.com/", + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-tip-get-help" + : "urlbar-tip-help-icon", + }, + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "http://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), +]; diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_noEvent.js b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_noEvent.js new file mode 100644 index 0000000000..bdba6888b7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry_noEvent.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const tests = [ + async function (win) { + info("Type something, click on search settings."); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "about:blank" }, + async browser => { + win.gURLBar.select(); + const promise = onSyncPaneLoaded(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + fireInputEvent: true, + }); + UrlbarTestUtils.getOneOffSearchButtons(win).settingsButton.click(); + await promise; + } + ); + return null; + }, + + async function (win) { + info("Type something, Up, Enter on search settings."); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "about:blank" }, + async browser => { + win.gURLBar.select(); + const promise = onSyncPaneLoaded(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "x", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_ArrowUp", {}, win); + Assert.ok( + UrlbarTestUtils.getOneOffSearchButtons( + win + ).selectedButton.classList.contains("search-setting-button"), + "Should have selected the settings button" + ); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + } + ); + return null; + }, +]; + +function onSyncPaneLoaded() { + return new Promise(resolve => { + Services.obs.addObserver(function panesLoadedObs() { + Services.obs.removeObserver(panesLoadedObs, "sync-pane-loaded"); + resolve(); + }, "sync-pane-loaded"); + }); +} + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.eventTelemetry.enabled", true]], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow(); + + // This is not necessary after each loop, because assertEvents does it. + Services.telemetry.clearEvents(); + + for (let i = 0; i < tests.length; i++) { + info(`Running no event test at index ${i}`); + await tests[i](win); + // Always blur to ensure it's not accounted as an additional abandonment. + win.gURLBar.blur(); + TelemetryTestUtils.assertEvents([], { category: "urlbar" }); + } + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_selection.js b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js new file mode 100644 index 0000000000..3440c35e6f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js @@ -0,0 +1,307 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const exampleSearch = "f oo bar"; +const exampleUrl = "https://example.com/1"; + +function click(target) { + let promise = BrowserTestUtils.waitForEvent(target, "click"); + EventUtils.synthesizeMouseAtCenter(target, {}, target.ownerGlobal); + return promise; +} + +function openContextMenu(target) { + let popupShownPromise = BrowserTestUtils.waitForEvent( + target.ownerGlobal, + "contextmenu" + ); + + EventUtils.synthesizeMouseAtCenter( + target, + { + type: "contextmenu", + button: 2, + }, + target.ownerGlobal + ); + return popupShownPromise; +} + +function drag(target, fromX, fromY, toX, toY) { + let promise = BrowserTestUtils.waitForEvent(target, "mouseup"); + EventUtils.synthesizeMouse( + target, + fromX, + fromY, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + fromX, + fromY, + { type: "mousedown" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + toY, + { type: "mousemove" }, + target.ownerGlobal + ); + EventUtils.synthesizeMouse( + target, + toX, + toY, + { type: "mouseup" }, + target.ownerGlobal + ); + return promise; +} + +function resetPrimarySelection(val = "") { + if ( + Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ) + ) { + // Reset the clipboard. + clipboardHelper.copyStringToClipboard( + val, + Services.clipboard.kSelectionClipboard + ); + } +} + +function checkPrimarySelection(expectedVal = "") { + if ( + Services.clipboard.isClipboardTypeSupported( + Services.clipboard.kSelectionClipboard + ) + ) { + let primaryAsText = SpecialPowers.getClipboardData( + "text/plain", + SpecialPowers.Ci.nsIClipboard.kSelectionClipboard + ); + Assert.equal(primaryAsText, expectedVal); + } +} + +add_setup(async function () { + // On macOS, we must "warm up" the Urlbar to get the first test to pass. + gURLBar.value = ""; + await click(gURLBar.inputField); + gURLBar.blur(); +}); + +add_task(async function leftClickSelectsAll() { + resetPrimarySelection(); + gURLBar.value = exampleSearch; + await click(gURLBar.inputField); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.length, + "The entire search term should be selected." + ); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function leftClickSelectsUrl() { + resetPrimarySelection(); + gURLBar.value = exampleUrl; + await click(gURLBar.inputField); + Assert.equal(gURLBar.selectionStart, 0, "The entire url should be selected."); + Assert.equal( + gURLBar.selectionEnd, + exampleUrl.length, + "The entire url should be selected." + ); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function rightClickSelectsAll() { + gURLBar.inputField.focus(); + gURLBar.value = exampleUrl; + + // Remove the selection so the focus() call above doesn't influence the test. + gURLBar.selectionStart = gURLBar.selectionEnd = 0; + + resetPrimarySelection(); + + await openContextMenu(gURLBar.inputField); + + Assert.equal(gURLBar.selectionStart, 0, "The entire URL should be selected."); + Assert.equal( + gURLBar.selectionEnd, + exampleUrl.length, + "The entire URL should be selected." + ); + + checkPrimarySelection(); + + let contextMenu = gURLBar.querySelector("moz-input-box").menupopup; + + // While the context menu is open, test the "Select All" button. + let contextMenuItem = contextMenu.firstElementChild; + while ( + contextMenuItem.nextElementSibling && + contextMenuItem.getAttribute("cmd") != "cmd_selectAll" + ) { + contextMenuItem = contextMenuItem.nextElementSibling; + } + Assert.ok( + contextMenuItem, + "The context menu should have the select all menu item." + ); + + let controller = document.commandDispatcher.getControllerForCommand( + contextMenuItem.getAttribute("cmd") + ); + let enabled = controller.isCommandEnabled( + contextMenuItem.getAttribute("cmd") + ); + Assert.ok(enabled, "The context menu select all item should be enabled."); + + await click(contextMenuItem); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire URL should be selected after clicking selectAll button." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleUrl.length, + "The entire URL should be selected after clicking selectAll button." + ); + + gURLBar.querySelector("moz-input-box").menupopup.hidePopup(); + gURLBar.blur(); + checkPrimarySelection(gURLBar.value); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function contextMenuDoesNotCancelSelection() { + gURLBar.inputField.focus(); + gURLBar.value = exampleUrl; + + gURLBar.selectionStart = 3; + gURLBar.selectionEnd = 7; + + resetPrimarySelection(); + + await openContextMenu(gURLBar.inputField); + + Assert.equal( + gURLBar.selectionStart, + 3, + "The selection should not have changed." + ); + Assert.equal( + gURLBar.selectionEnd, + 7, + "The selection should not have changed." + ); + + gURLBar.querySelector("moz-input-box").menupopup.hidePopup(); + gURLBar.blur(); + checkPrimarySelection(); +}); + +add_task(async function dragSelect() { + resetPrimarySelection(); + gURLBar.value = exampleSearch.repeat(10); + // Drags from an artibrary offset of 30 to test for bug 1562145: that the + // selection does not start at the beginning. + await drag(gURLBar.inputField, 30, 0, 60, 0); + Assert.greater( + gURLBar.selectionStart, + 0, + "Selection should not start at the beginning of the string." + ); + + let selectedVal = gURLBar.value.substring( + gURLBar.selectionStart, + gURLBar.selectionEnd + ); + gURLBar.blur(); + checkPrimarySelection(selectedVal); +}); + +/** + * Testing for bug 1571018: that the entire Urlbar isn't selected when the + * Urlbar is dragged following a selectsAll event then a blur. + */ +add_task(async function dragAfterSelectAll() { + resetPrimarySelection(); + gURLBar.value = exampleSearch.repeat(10); + await click(gURLBar.inputField); + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.repeat(10).length, + "The entire search term should be selected." + ); + + gURLBar.blur(); + checkPrimarySelection(); + + // The offset of 30 is arbitrary. + await drag(gURLBar.inputField, 30, 0, 60, 0); + + Assert.notEqual( + gURLBar.selectionStart, + 0, + "Only part of the search term should be selected." + ); + Assert.notEqual( + gURLBar.selectionEnd, + exampleSearch.repeat(10).length, + "Only part of the search term should be selected." + ); + + checkPrimarySelection( + gURLBar.value.substring(gURLBar.selectionStart, gURLBar.selectionEnd) + ); +}); + +/** + * Testing for bug 1571018: that the entire Urlbar is selected when the Urlbar + * is refocused following a partial text selection then a blur. + */ +add_task(async function selectAllAfterDrag() { + gURLBar.value = exampleSearch; + + gURLBar.selectionStart = 3; + gURLBar.selectionEnd = 7; + + gURLBar.blur(); + + await click(gURLBar.inputField); + + Assert.equal( + gURLBar.selectionStart, + 0, + "The entire search term should be selected." + ); + Assert.equal( + gURLBar.selectionEnd, + exampleSearch.length, + "The entire search term should be selected." + ); + + gURLBar.blur(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js new file mode 100644 index 0000000000..4e48a946d5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js @@ -0,0 +1,1218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with search related actions. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; +const SCALAR_SEARCHMODE = "browser.engagement.navigation.urlbar_searchmode"; + +// The preference to enable suggestions in the urlbar. +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +function searchInAwesomebar(value, win = window) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus, + value, + fireInputEvent: true, + }); +} + +/** + * Click one of the entries in the urlbar suggestion popup. + * + * @param {string} resultTitle + * The title of the result to click on. + * @param {number} button [optional] + * which button to click. + * @returns {number} + * The index of the result that was clicked, or -1 if not found. + */ +async function clickURLBarSuggestion(resultTitle, button = 1) { + await UrlbarTestUtils.promiseSearchComplete(window); + + const count = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < count; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.displayed.title == resultTitle) { + // This entry is the search suggestion we're looking for. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + i + ); + if (button == 1) { + EventUtils.synthesizeMouseAtCenter(element, {}); + } else if (button == 2) { + EventUtils.synthesizeMouseAtCenter(element, { + type: "mousedown", + button: 2, + }); + } + return i; + } + } + return -1; +} + +/** + * Create an engine to generate search suggestions and add it as default + * for this test. + * + * @param {Function} taskFn + * The function to run with the new search engine as default. + */ +async function withNewSearchEngine(taskFn) { + let suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml", + }); + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + try { + await taskFn(suggestionEngine); + } finally { + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(suggestionEngine); + } +} + +add_setup(async function () { + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + search_url: "https://example.com/", + }, + { setAsDefault: true } + ); + + // Make it the first one-off engine. + let engine = Services.search.getEngineByName("MozSearch"); + await Services.search.moveEngine(engine, 0); + + // Enable search suggestions in the urlbar. + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + + // Clear historical search suggestions to avoid interference from previous + // tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + // This test assumes that general results are shown before suggestions. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchSuggestionsFirst", false]], + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_simpleQuery() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Simulate entering a simple search."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented, but only the urlbar source since an + // internal @search keyword was not used. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_searchMode_enter() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Enter search mode using an alias and a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("mozalias query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the first result, a one-off button, and the Return +// (Enter) key. +add_task(async function test_oneOff_enter() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info("Pressing Alt+Down to take us to the first one-off engine."); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let engine = + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.engine; + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented, but only the urlbar-searchmode source + // since aliases aren't counted separately in search mode. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-searchmode", + 1 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the second result, a one-off button, and the Return +// (Enter) key. This only tests the FX_URLBAR_SELECTED_RESULT_METHOD histogram +// since test_oneOff_enter covers everything else. +add_task(async function test_oneOff_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info( + "Select the second result, press Alt+Down to take us to the first one-off engine." + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + let engine = + UrlbarTestUtils.getOneOffSearchButtons(window).selectedButton.engine; + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab); + }); +}); + +// Performs a search using a click on a one-off button. This only tests the +// FX_URLBAR_SELECTED_RESULT_METHOD histogram since test_oneOff_enter covers +// everything else. +add_task(async function test_oneOff_click() { + Services.telemetry.clearScalars(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + + info("Click the first one-off button."); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + let oneOffButton = + UrlbarTestUtils.getOneOffSearchButtons(window).getSelectableButtons( + false + )[0]; + oneOffButton.click(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: oneOffButton.engine.name, + entry: "oneoff", + }); + + // Now that we're in search mode, execute the search. + let element = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.ok(element, "Found result after entering search mode."); + EventUtils.synthesizeMouseAtCenter(element, {}); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Clicks the first suggestion offered by the test search engine. +add_task(async function test_suggestion_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await UrlbarTestUtils.formHistory.clear(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Clicking the urlbar suggestion."); + await clickURLBarSuggestion("queryfoo"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "suggestion", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine. This only tests the FX_URLBAR_SELECTED_RESULT_METHOD +// histogram since test_suggestion_click covers everything else. +add_task(async function test_suggestion_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through tab and presses the Return (Enter) key on the first +// suggestion offered by the test search engine. +add_task(async function test_suggestion_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through code and presses the Return (Enter) key on the first +// suggestion offered by the test search engine. +add_task(async function test_suggestion_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + info("Select the second result and press Return."); + UrlbarTestUtils.setSelectedRowIndex(window, 1); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Clicks the first suggestion offered by the test search engine when in search +// mode. +add_task(async function test_searchmode_suggestion_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Clicking the urlbar suggestion."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await clickURLBarSuggestion("queryfoo"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHMODE, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHMODE]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar-searchmode", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_searchmode", + "suggestion", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine in search mode. This only tests the +// FX_URLBAR_SELECTED_RESULT_METHOD histogram since +// test_searchmode_suggestion_click covers everything else. +add_task(async function test_searchmode_suggestion_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through tab and presses the Return (Enter) key on the first +// suggestion offered by the test search engine in search mode. +add_task(async function test_suggestion_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Selects through code and presses the Return (Enter) key on the first +// suggestion offered by the test search engine in search mode. +add_task(async function test_suggestion_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await withNewSearchEngine(async function (engine) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("query"); + await UrlbarTestUtils.enterSearchMode(window, { + engineName: engine.name, + }); + info("Select the second result and press Return."); + UrlbarTestUtils.setSelectedRowIndex(window, 1); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + }); +}); + +// Clicks a form history result. +add_task(async function test_formHistory_click() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + await withNewSearchEngine(async engine => { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Clicking the form history."); + await clickURLBarSuggestion("foobar"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR, + "search_formhistory", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_URLBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // SEARCH_COUNTS should be incremented. + let searchEngineId = "other-" + engine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".urlbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "formhistory", + { engine: searchEngineId }, + ], + ], + { category: "navigation", method: "search" } + ); + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects and presses the Return (Enter) key on a form history result. This +// only tests the FX_URLBAR_SELECTED_RESULT_METHOD histogram since +// test_formHistory_click covers everything else. +add_task(async function test_formHistory_arrowEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the form history result and press Return."); + while (gURLBar.untrimmedValue != "foobar") { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects through tab and presses the Return (Enter) key on a form history +// result. +add_task(async function test_formHistory_tabEnterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the form history result and press Return."); + while (gURLBar.untrimmedValue != "foobar") { + EventUtils.synthesizeKey("KEY_Tab"); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.tabEnterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +// Selects through code and presses the Return (Enter) key on a form history +// result. +add_task(async function test_formHistory_enterSelection() { + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ); + + await UrlbarTestUtils.formHistory.clear(); + await UrlbarTestUtils.formHistory.add(["foobar"]); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 1]], + }); + + await withNewSearchEngine(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. There should be form history."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("foo"); + info("Select the second result and press Return."); + let index = 1; + while (gURLBar.untrimmedValue != "foobar") { + UrlbarTestUtils.setSelectedRowIndex(window, index++); + } + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + TelemetryTestUtils.assertHistogram( + resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + 1 + ); + + BrowserTestUtils.removeTab(tab); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function test_privateWindow() { + // This test assumes the showSearchTerms feature is not enabled, + // as multiple searches are made one after another, relying on + // urlbar as the keyed scalar SAP, not urlbar_persisted. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); + + // Override the search telemetry search provider info to + // count in-content SEARCH_COUNTs telemetry for our test engine. + SearchSERPTelemetry.overrideSearchTelemetryForTests([ + { + telemetryId: "example", + searchPageRegexp: "^https://example\\.com/", + queryParamName: "q", + }, + ]); + + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + // First, do a bunch of searches in a private window. + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + info("Search in a private window and the pref does not exist"); + let p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + console.log(scalars); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 1 + ); + + info("Search again in a private window after setting the pref to true"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", true); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should *not* be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 1 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 1 + ); + + info("Search again in a private window after setting the pref to false"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", false); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 2 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 2 + ); + + info("Search again in a private window after clearing the pref"); + Services.prefs.clearUserPref("browser.engagement.search_counts.pbm"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 3 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 3 + ); + + await BrowserTestUtils.closeWindow(win); + + // Now, do a bunch of searches in a non-private window. Telemetry should + // always be recorded regardless of the pref's value. + win = await BrowserTestUtils.openNewBrowserWindow(); + + info("Search in a non-private window and the pref does not exist"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 4 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 4 + ); + + info("Search again in a non-private window after setting the pref to true"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", true); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 5 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 5 + ); + + info("Search again in a non-private window after setting the pref to false"); + Services.prefs.setBoolPref("browser.engagement.search_counts.pbm", false); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 6 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 6 + ); + + info("Search again in a non-private window after clearing the pref"); + Services.prefs.clearUserPref("browser.engagement.search_counts.pbm"); + p = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await searchInAwesomebar("another query", win); + EventUtils.synthesizeKey("KEY_Enter", undefined, win); + await p; + + // SEARCH_COUNTS should be incremented. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + 7 + ); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.urlbar", + "example:organic:none", + 7 + ); + + await BrowserTestUtils.closeWindow(win); + + // Reset the search provider info. + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + await UrlbarTestUtils.formHistory.clear(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js new file mode 100644 index 0000000000..8336bde462 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_autofill.js @@ -0,0 +1,733 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests urlbar autofill telemetry. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderPreloadedSites: + "resource:///modules/UrlbarProviderPreloadedSites.sys.mjs", +}); + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // SEARCH_COUNTS should not contain any engine counts at all. The keys in this + // histogram are search engine telemetry identifiers. + Assert.deepEqual( + Object.keys(search_hist.snapshot()), + [], + "SEARCH_COUNTS is empty" + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Performs a search and picks the first result. + * + * @param {string} searchString + * The search string. Assumed to trigger an autofill result + * @param {string} autofilledValue + * The input's expected value after autofill occurs. + * @param {string} unpickResult + * Optional: If true, do not pick any result. Default value is false. + * @param {string} urlToSelect + * Optional: If want to select result except autofill, pass the URL. + */ +async function triggerAutofillAndPickResult( + searchString, + autofilledValue, + unpickResult = false, + urlToSelect = null +) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "Result is autofill"); + Assert.equal(gURLBar.value, autofilledValue, "gURLBar.value"); + Assert.equal(gURLBar.selectionStart, searchString.length, "selectionStart"); + Assert.equal(gURLBar.selectionEnd, autofilledValue.length, "selectionEnd"); + + if (urlToSelect) { + for (let row = 0; row < UrlbarTestUtils.getResultCount(window); row++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, row); + if (result.url === urlToSelect) { + UrlbarTestUtils.setSelectedRowIndex(window, row); + break; + } + } + } + + if (unpickResult) { + // Close popup without any action. + await UrlbarTestUtils.promisePopupClose(window); + return; + } + + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + let url; + if (urlToSelect) { + url = urlToSelect; + } else { + url = autofilledValue.includes(":") + ? autofilledValue + : "http://" + autofilledValue; + } + Assert.equal(gBrowser.currentURI.spec, url, "Loaded URL is correct"); + }); +} + +function createOtherAutofillProvider(searchString, autofilledValue) { + return new UrlbarTestUtils.TestProvider({ + priority: Infinity, + type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + title: "Test", + url: "http://example.com/", + } + ), + { + heuristic: true, + autofill: { + value: autofilledValue, + selectionStart: searchString.length, + selectionEnd: autofilledValue.length, + // Leave out `type` to trigger "other" + }, + } + ), + ], + }); +} + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.clearInputHistory(); + + // Enable local telemetry recording for the duration of the tests. + const originalCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = originalCanRecord; + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + }); +}); + +// Checks adaptive history, origin, and URL autofill. +add_task(async function history() { + const testData = [ + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "ex", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exam", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/test", + autofilled: "example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test", "http://example.org/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.org", + autofilled: "example.org/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: true, + visitHistory: ["http://example.com/test", "http://example.com/test/url"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/test/", + autofilled: "example.com/test/", + expected: "autofill_url", + }, + { + useAdaptiveHistory: true, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http://example.com/test", + autofilled: "http://example.com/test", + expected: "autofill_adaptive", + }, + { + useAdaptiveHistory: false, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + useAdaptiveHistory: false, + visitHistory: [{ uri: "http://example.com/test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/te", + autofilled: "example.com/test", + expected: "autofill_url", + }, + ]; + + for (const { + useAdaptiveHistory, + visitHistory, + inputHistory, + userInput, + autofilled, + expected, + } of testData) { + const histograms = snapshotHistograms(); + + await PlacesTestUtils.addVisits(visitHistory); + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", useAdaptiveHistory); + + await triggerAutofillAndPickResult(userInput, autofilled); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + expected, + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + } +}); + +// Checks about-page autofill (e.g., "about:about"). +add_task(async function about() { + let histograms = snapshotHistograms(); + await triggerAutofillAndPickResult("about:abou", "about:about"); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill_about", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await PlacesUtils.history.clear(); +}); + +// Checks preloaded sites autofill. +add_task(async function preloaded() { + UrlbarPrefs.set("usepreloadedtopurls.enabled", true); + UrlbarPrefs.set("usepreloadedtopurls.expire_days", 100); + UrlbarProviderPreloadedSites.populatePreloadedSiteStorage([ + ["http://example.com/", "Example"], + ]); + + let histograms = snapshotHistograms(); + await triggerAutofillAndPickResult("example", "example.com/"); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill_preloaded", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await PlacesUtils.history.clear(); + UrlbarPrefs.clear("usepreloadedtopurls.enabled"); + UrlbarPrefs.clear("usepreloadedtopurls.expire_days"); +}); + +// Checks the "other" fallback, which shouldn't normally happen. +add_task(async function other() { + let searchString = "exam"; + let autofilledValue = "example.com/"; + let provider = createOtherAutofillProvider(searchString, autofilledValue); + UrlbarProvidersManager.registerProvider(provider); + + let histograms = snapshotHistograms(); + await triggerAutofillAndPickResult(searchString, autofilledValue); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill_other", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await PlacesUtils.history.clear(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +// Checks impression telemetry. +add_task(async function impression() { + const testData = [ + { + description: "Adaptive history autofill and pick it", + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + autofilled: "example.com/first", + expected: "autofill_adaptive", + }, + { + description: "Adaptive history autofill but pick another result", + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + urlToSelect: "http://example.com/second", + autofilled: "example.com/first", + expected: "autofill_adaptive", + }, + { + description: "Adaptive history autofill but not pick any result", + unpickResult: true, + useAdaptiveHistory: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + inputHistory: [{ uri: "http://example.com/first", input: "exa" }], + userInput: "exa", + autofilled: "example.com/first", + }, + { + description: "Origin autofill and pick it", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + description: "Origin autofill but pick another result", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + urlToSelect: "http://example.com/second", + autofilled: "example.com/", + expected: "autofill_origin", + }, + { + description: "Origin autofill but not pick any result", + unpickResult: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "exa", + autofilled: "example.com/", + }, + { + description: "URL autofill and pick it", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + autofilled: "example.com/", + expected: "autofill_url", + }, + { + description: "URL autofill but pick another result", + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + urlToSelect: "http://example.com/second", + autofilled: "example.com/", + expected: "autofill_url", + }, + { + description: "URL autofill but not pick any result", + unpickResult: true, + visitHistory: ["http://example.com/first", "http://example.com/second"], + userInput: "example.com/", + autofilled: "example.com/", + }, + { + description: "about page autofill and pick it", + userInput: "about:a", + autofilled: "about:about", + expected: "autofill_about", + }, + { + description: "about page autofill but pick another result", + userInput: "about:a", + urlToSelect: "about:addons", + autofilled: "about:about", + expected: "autofill_about", + }, + { + description: "about page autofill but not pick any result", + unpickResult: true, + userInput: "about:a", + autofilled: "about:about", + }, + { + description: "Preloaded site autofill and pick it", + usePreloadedSite: true, + preloadedSites: [["http://example.com/", "Example"]], + userInput: "exa", + autofilled: "example.com/", + expected: "autofill_preloaded", + }, + { + description: "Preloaded site autofill but not pick any result", + unpickResult: true, + usePreloadedSite: true, + preloadedSites: [["http://example.com/", "Example"]], + userInput: "exa", + autofilled: "example.com/", + }, + { + description: "Other provider's autofill and pick it", + useOtherProvider: true, + userInput: "example", + autofilled: "example.com/", + expected: "autofill_other", + }, + { + description: "Other provider's autofill but not pick any result", + unpickResult: true, + useOtherProvider: true, + userInput: "example", + autofilled: "example.com/", + }, + ]; + + for (const { + description, + useAdaptiveHistory = false, + usePreloadedSite = false, + useOtherProvider = false, + unpickResult = false, + visitHistory, + inputHistory, + preloadedSites, + userInput, + select, + autofilled, + expected, + } of testData) { + info(description); + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", useAdaptiveHistory); + if (usePreloadedSite) { + UrlbarPrefs.set("usepreloadedtopurls.enabled", true); + UrlbarPrefs.set("usepreloadedtopurls.expire_days", 100); + } + let otherProvider; + if (useOtherProvider) { + otherProvider = createOtherAutofillProvider(userInput, autofilled); + UrlbarProvidersManager.registerProvider(otherProvider); + } + + if (visitHistory) { + await PlacesTestUtils.addVisits(visitHistory); + } + if (inputHistory) { + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + } + if (preloadedSites) { + UrlbarProviderPreloadedSites.populatePreloadedSiteStorage(preloadedSites); + } + + await triggerAutofillAndPickResult( + userInput, + autofilled, + unpickResult, + select + ); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + if (unpickResult) { + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_adaptive" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_origin" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_url" + ); + TelemetryTestUtils.assertScalarUnset( + scalars, + "urlbar.impression.autofill_about" + ); + } else { + TelemetryTestUtils.assertScalar( + scalars, + `urlbar.impression.${expected}`, + 1 + ); + } + + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + UrlbarPrefs.clear("usepreloadedtopurls.enabled"); + UrlbarPrefs.clear("usepreloadedtopurls.expire_days"); + + if (otherProvider) { + UrlbarProvidersManager.unregisterProvider(otherProvider); + } + + await PlacesTestUtils.clearInputHistory(); + await PlacesUtils.history.clear(); + } +}); + +// Checks autofill deletion telemetry. +add_task(async function deletion() { + await PlacesTestUtils.addVisits(["http://example.com/"]); + + info("Delete autofilled value by DELETE key"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + EventUtils.synthesizeKey("KEY_Delete"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + info("Delete autofilled value by BACKSPACE key"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + info("Delete autofilled value twice"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Delete autofilled string. + EventUtils.synthesizeKey("KEY_Delete"); + Assert.equal(gURLBar.value, "exa"); + + // Re-autofilling. + EventUtils.synthesizeKey("m"); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "exam".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Delete autofilled string again. + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exam"); + }, + expectedScalar: 2, + }); + + info("Delete one char after unselecting autofilled string"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "example.com"); + }, + expectedScalar: 0, + }); + + info("Delete autofilled value after unselecting autofilled string"); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Delete autofilled string one by one. + for (let i = 0; i < "mple.com/".length; i++) { + EventUtils.synthesizeKey("KEY_Backspace"); + } + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 0, + }); + + info( + "Delete autofilled value after unselecting autofilled string then selecting them manually again" + ); + await doDeletionTest({ + firstSearchString: "exa", + firstAutofilledValue: "example.com/", + trigger: () => { + // Cancel selection. + const previousSelectionStart = gURLBar.selectionStart; + const previousSelectionEnd = gURLBar.selectionEnd; + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + Assert.equal(gURLBar.selectionStart, "example.com/".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Select same range again. + gURLBar.selectionStart = previousSelectionStart; + gURLBar.selectionEnd = previousSelectionEnd; + + EventUtils.synthesizeKey("KEY_Backspace"); + Assert.equal(gURLBar.value, "exa"); + }, + expectedScalar: 1, + }); + + await PlacesUtils.history.clear(); +}); + +async function doDeletionTest({ + firstSearchString, + firstAutofilledValue, + trigger, + expectedScalar, +}) { + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstSearchString, + fireInputEvent: true, + }); + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill, "Result is autofill"); + Assert.equal(gURLBar.value, firstAutofilledValue, "gURLBar.value"); + Assert.equal( + gURLBar.selectionStart, + firstSearchString.length, + "selectionStart" + ); + Assert.equal( + gURLBar.selectionEnd, + firstAutofilledValue.length, + "selectionEnd" + ); + + await trigger(); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + if (expectedScalar) { + TelemetryTestUtils.assertScalar( + scalars, + "urlbar.autofill_deletion", + expectedScalar + ); + } else { + TelemetryTestUtils.assertScalarUnset(scalars, "urlbar.autofill_deletion"); + } + + await UrlbarTestUtils.promisePopupClose(window); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js new file mode 100644 index 0000000000..d4f4e77d57 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for dynamic results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +const DYNAMIC_TYPE_NAME = "test"; + +/** + * A test URLBar provider. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ + priority: Infinity, + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: DYNAMIC_TYPE_NAME, + } + ), + { heuristic: true } + ), + ], + }); + } + + getViewUpdate(result, idsByName) { + return { + title: { + textContent: "This is a dynamic result.", + }, + button: { + textContent: "Click Me", + }, + }; + } +} + +add_task(async function test() { + // Add a dynamic result type. + UrlbarResult.addDynamicResultType(DYNAMIC_TYPE_NAME); + UrlbarView.addDynamicViewTemplate(DYNAMIC_TYPE_NAME, { + stylesheet: + getRootDirectory(gTestPath) + "urlbarTelemetryUrlbarDynamic.css", + children: [ + { + name: "title", + tag: "span", + }, + { + name: "buttonSpacer", + tag: "span", + }, + { + name: "button", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }); + registerCleanupFunction(() => { + UrlbarView.removeDynamicViewTemplate(DYNAMIC_TYPE_NAME); + UrlbarResult.removeDynamicResultType(DYNAMIC_TYPE_NAME); + }); + + // Register a provider that returns the dynamic result type. + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(provider); + }); + + const histograms = snapshotHistograms(); + + // Do a search to show the dynamic result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + fireInputEvent: true, + }); + + // Press enter on the result's button. It will be preselected since the + // result is the heuristic. + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + assertTelemetryResults( + histograms, + "dynamic", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + // Clean up for subsequent tests. + gURLBar.handleRevert(); +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js new file mode 100644 index 0000000000..28eae06a6f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js @@ -0,0 +1,155 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with extension actions. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_extension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + // Just do nothing for this test. + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }, + }); + + await extension.startup(); + + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "omniboxtest ", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "extension", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js new file mode 100644 index 0000000000..c2e1413a27 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_handoff.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchSERPTelemetry } = ChromeUtils.importESModule( + "resource:///modules/SearchSERPTelemetry.sys.mjs" +); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.com\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + }, +]; + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.com/browser/browser/components/search/test/browser/${page}`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + ], + }); + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + const oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.setEventRecordingEnabled("navigation", false); + + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + }); +}); + +add_task(async function test_search() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + const histogram = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + info("Load about:newtab in new window"); + const newtab = "about:newtab"; + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + BrowserTestUtils.loadURIString(tab.linkedBrowser, newtab); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, newtab); + + info("Focus on search input in newtab content"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + + info("Search and wait the result"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("q"); + EventUtils.synthesizeKey("VK_RETURN"); + await onLoaded; + + info("Check the telemetries"); + await assertHandoffResult(histogram); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_search_private_mode() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + const histogram = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + info("Open private window"); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let tab = privateWindow.gBrowser.selectedTab; + + info("Focus on search input in newtab content"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + + info("Search and wait the result"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("q", {}, privateWindow); + EventUtils.synthesizeKey("VK_RETURN", {}, privateWindow); + await onLoaded; + + info("Check the telemetries"); + await assertHandoffResult(histogram); + + await BrowserTestUtils.closeWindow(privateWindow); +}); + +async function assertHandoffResult(histogram) { + await assertScalars([ + ["browser.engagement.navigation.urlbar_handoff", "search_enter", 1], + ["browser.search.content.urlbar_handoff", "example:tagged:ff", 1], + ]); + await assertHistogram(histogram, [["other-Example.urlbar-handoff", 1]]); + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar_handoff", + "enter", + { engine: "other-Example" }, + ], + ], + { category: "navigation", method: "search" } + ); +} + +async function assertHistogram(histogram, expectedResults) { + await TestUtils.waitForCondition(() => { + const snapshot = histogram.snapshot(); + return expectedResults.every(([key]) => key in snapshot); + }, "Wait until the histogram has expected keys"); + + for (const [key, value] of expectedResults) { + TelemetryTestUtils.assertKeyedHistogramSum(histogram, key, value); + } +} + +async function assertScalars(expectedResults) { + await TestUtils.waitForCondition(() => { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true); + return expectedResults.every(([scalarName]) => scalarName in scalars); + }, "Wait until the scalars have expected keyed scalars"); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true); + + for (const [scalarName, key, value] of expectedResults) { + TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, value); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js new file mode 100644 index 0000000000..904e774a2c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_persisted.js @@ -0,0 +1,270 @@ +/* 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/. */ + +/** + * This file tests browser.engagement.navigation.urlbar_persisted and the + * event navigation.search.urlbar_persisted + */ + +"use strict"; + +const { SearchSERPTelemetry } = ChromeUtils.importESModule( + "resource:///modules/SearchSERPTelemetry.sys.mjs" +); + +const SCALAR_URLBAR_PERSISTED = + "browser.engagement.navigation.urlbar_persisted"; + +const SEARCH_STRING = "chocolate"; + +let testEngine; +add_setup(async () => { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", true]], + }); + + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + search_url: "https://www.example.com/", + search_url_get_params: "q={searchTerms}&pc=fake_code", + }, + { setAsDefault: true } + ); + + testEngine = Services.search.getEngineByName("MozSearch"); + + // Enable event recording for the events. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +async function searchForString(searchString, tab) { + info(`Search for string: ${searchString}.`); + let [expectedSearchUrl] = UrlbarUtils.getSearchQueryUrl( + testEngine, + searchString + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + expectedSearchUrl + ); + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: searchString, + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + await browserLoadedPromise; + info("Finished loading search."); + return expectedSearchUrl; +} + +async function gotoUrl(url, tab) { + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false, + url + ); + BrowserTestUtils.loadURIString(tab.linkedBrowser, url); + await browserLoadedPromise; + info(`Loaded page: ${url}`); +} + +async function goBack(browser) { + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goBack(); + await pageShowPromise; + info("Go back a page."); +} + +async function goForward(browser) { + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goForward(); + await pageShowPromise; + info("Go forward a page."); +} + +function assertScalarSearchEnter(number) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_URLBAR_PERSISTED, + "search_enter", + number + ); +} + +function assertScalarDoesNotExist(scalar) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok(!(scalar in scalars), scalar + " must not be recorded."); +} + +function assertTelemetryEvents() { + TelemetryTestUtils.assertEvents( + [ + [ + "navigation", + "search", + "urlbar", + "enter", + { engine: "other-MozSearch" }, + ], + [ + "navigation", + "search", + "urlbar_persisted", + "enter", + { engine: "other-MozSearch" }, + ], + ], + { + category: "navigation", + method: "search", + } + ); +} + +// A user making a search after making a search should result +// in the telemetry being recorded. +add_task(async function search_after_search() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab); + + // Scalar should not exist from a blank page, only when a search + // is conducted from a default SERP. + await assertScalarDoesNotExist(SCALAR_URLBAR_PERSISTED); + + // After the first search, we should expect the SAP to change + // because the search term should show up on the SERP. + await searchForString(SEARCH_STRING, tab); + assertScalarSearchEnter(1); + + // Check search counts. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); + +// A user going to a tab that contains a SERP should +// trigger the telemetry when conducting a search. +add_task(async function switch_to_tab_and_search() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab1); + + const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await gotoUrl("https://www.example.com/some-place", tab2); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await searchForString(SEARCH_STRING, tab1); + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +// When a user reverts the Urlbar after the search terms persist, +// conducting another search should still be registered as a +// urlbar-persisted SAP. +add_task(async function handle_revert() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + await searchForString(SEARCH_STRING, tab); + + gURLBar.handleRevert(); + await searchForString(SEARCH_STRING, tab); + + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); + +// A user going back and forth in history should trigger +// urlbar-persisted telemetry when returning to a SERP +// and conducting a search. +add_task(async function back_and_forth() { + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Create three pages in history: a page, a SERP, and a page. + await gotoUrl("https://www.example.com/some-place", tab); + await searchForString(SEARCH_STRING, tab); + await gotoUrl("https://www.example.com/another-page", tab); + + // Go back to the SERP by using both back and forward. + await goBack(tab.linkedBrowser); + await goBack(tab.linkedBrowser); + await goForward(tab.linkedBrowser); + await assertScalarDoesNotExist(SCALAR_URLBAR_PERSISTED); + + // Then do a search. + await searchForString(SEARCH_STRING, tab); + assertScalarSearchEnter(1); + + // Check search count. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar-persisted", + 1 + ); + + // Check events. + assertTelemetryEvents(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js new file mode 100644 index 0000000000..26500033eb --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js @@ -0,0 +1,270 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with places related actions (e.g. history/ + * bookmark selection). + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +const TEST_URL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888" +); + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function searchInAwesomebar(value, win = window) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + waitForFocus, + value, + fireInputEvent: true, + }); +} + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await PlacesUtils.keywords.insert({ + keyword: "get", + url: TEST_URL + "?q=%s", + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + await PlacesUtils.keywords.remove("get"); + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_history() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "example", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "history", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_bookmark() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let bm = await PlacesUtils.bookmarks.insert({ + url: "http://example.com", + title: "example", + parentGuid: PlacesUtils.bookmarks.menuGuid, + }); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "bookmark", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + await PlacesUtils.bookmarks.remove(bm); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_keyword() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("get example"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "keyword", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_switchtab() { + const histograms = snapshotHistograms(); + + let homeTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:buildconfig" + ); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + let p = BrowserTestUtils.waitForEvent(gBrowser, "TabSwitchDone"); + await searchInAwesomebar("about:buildconfig"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "switchtab", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(homeTab); +}); + +add_task(async function test_visitURL() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("http://example.com/a/"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "visiturl", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js new file mode 100644 index 0000000000..b29807900b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_quickactions.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for quickactions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +let testActionCalled = 0; + +add_setup(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.quickactions.enabled", true], + ], + }); + + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + label: "quickactions-downloads2", + onPick: () => testActionCalled++, + }); + + registerCleanupFunction(() => { + UrlbarProviderQuickActions.removeAction("testaction"); + }); +}); + +add_task(async function test() { + const histograms = snapshotHistograms(); + + // Do a search to show the quickaction. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "testaction", + waitForFocus, + fireInputEvent: true, + }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + }); + + Assert.equal(testActionCalled, 1, "Test action was called"); + + TelemetryTestUtils.assertHistogram( + histograms.resultMethodHist, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection, + 1 + ); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + `urlbar.picked.quickaction`, + 1, + 1 + ); + + TelemetryTestUtils.assertKeyedScalar( + scalars, + "quickaction.picked", + "testaction-10", + 1 + ); + + TelemetryTestUtils.assertKeyedScalar( + scalars, + "quickaction.impression", + "testaction-10", + 1 + ); + + // Clean up for subsequent tests. + gURLBar.handleRevert(); +}); + +add_task(async function test_impressions() { + UrlbarProviderQuickActions.addAction("testaction2", { + commands: ["testaction2"], + label: "quickactions-downloads2", + onPick: () => {}, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "testaction", + waitForFocus, + fireInputEvent: true, + }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + }); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + + TelemetryTestUtils.assertKeyedScalar( + scalars, + "quickaction.impression", + `testaction-10`, + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "quickaction.impression", + `testaction2-10`, + 1 + ); + + UrlbarProviderQuickActions.removeAction("testaction2"); + gURLBar.handleRevert(); +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + }; +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js new file mode 100644 index 0000000000..ffa3158f2b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js @@ -0,0 +1,185 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry with remote tab action. + */ + +"use strict"; + +const SCALAR_URLBAR = "browser.engagement.navigation.urlbar"; + +ChromeUtils.defineESModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function assertSearchTelemetryEmpty(search_hist) { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + Assert.ok( + !(SCALAR_URLBAR in scalars), + `Should not have recorded ${SCALAR_URLBAR}` + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.urlbar", + undefined + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.alias", + undefined + ); + + // Also check events. + let events = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + false + ); + events = (events.parent || []).filter( + e => e[1] == "navigation" && e[2] == "search" + ); + Assert.deepEqual( + events, + [], + "Should not have recorded any navigation search events" + ); +} + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + // Special prefs for remote tabs. + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "http://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_remotetab() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "example", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "remotetab", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js new file mode 100644 index 0000000000..7830102cf6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js @@ -0,0 +1,592 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests the urlbar.searchmode.* scalars telemetry with search mode + * related actions. + */ + +"use strict"; + +const ENTRY_SCALAR_PREFIX = "urlbar.searchmode."; +const PICKED_SCALAR_PREFIX = "urlbar.picked.searchmode."; +const ENGINE_ALIAS = "alias"; +const TEST_QUERY = "test"; +let engineName; +let engineDomain; + +// The preference to enable suggestions. +const SUGGEST_PREF = "browser.search.suggest.enabled"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "TouchBarHelper", + "@mozilla.org/widget/touchbarhelper;1", + "nsITouchBarHelper" +); + +/** + * Asserts that search mode telemetry was recorded correctly. Checks both the + * urlbar.searchmode.* and urlbar.searchmode_picked.* probes. + * + * @param {string} entry + * A search mode entry point. + * @param {string} engineOrSource + * An engine name or a search mode source. + * @param {number} [resultIndex] + * The index of the result picked while in search mode. Only pass this + * parameter if a result is picked. + */ +function assertSearchModeScalars(entry, engineOrSource, resultIndex = -1) { + // Check if the urlbar.searchmode.entry scalar contains the expected value. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + ENTRY_SCALAR_PREFIX + entry, + engineOrSource, + 1 + ); + + for (let e of UrlbarUtils.SEARCH_MODE_ENTRY) { + if (e == entry) { + Assert.equal( + Object.keys(scalars[ENTRY_SCALAR_PREFIX + entry]).length, + 1, + `This search must only increment one entry in the correct scalar: ${e}` + ); + } else { + Assert.ok( + !scalars[ENTRY_SCALAR_PREFIX + e], + `No other urlbar.searchmode scalars should be recorded. Checking ${e}` + ); + } + } + + if (resultIndex >= 0) { + TelemetryTestUtils.assertKeyedScalar( + scalars, + PICKED_SCALAR_PREFIX + entry, + resultIndex, + 1 + ); + } + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable tab-to-search onboarding results for general tests. They are + // enabled in tests that specifically address onboarding. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + + // Create an engine to generate search suggestions and add it as default + // for this test. + let suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml", + setAsDefault: true, + }); + suggestionEngine.alias = ENGINE_ALIAS; + engineDomain = suggestionEngine.searchUrlDomain; + engineName = suggestionEngine.name; + + // And the first one-off engine. + await Services.search.moveEngine(suggestionEngine, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Clear historical search suggestions to avoid interference from previous + // tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +// Clicks the first one off. +add_task(async function test_oneoff_remote() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("oneoff", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Clicks the history one off. +add_task(async function test_oneoff_local() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("oneoff", "history", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Checks that the Amazon search mode name is collapsed to "Amazon". +add_task(async function test_oneoff_amazon() { + // Disable suggestions to avoid hitting Amazon servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Amazon.com", + }); + assertSearchModeScalars("oneoff", "Amazon"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Checks that the Wikipedia search mode name is collapsed to "Wikipedia". +add_task(async function test_oneoff_wikipedia() { + // Disable suggestions to avoid hitting Wikipedia servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enters search mode by clicking a one-off. + await UrlbarTestUtils.enterSearchMode(window, { + engineName: "Wikipedia (en)", + }); + assertSearchModeScalars("oneoff", "Wikipedia"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by pressing the keyboard shortcut. +add_task(async function test_shortcut() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // Enter search mode by pressing the keyboard shortcut. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("k", { accelKey: true }); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + entry: "shortcut", + }); + assertSearchModeScalars("shortcut", "other"); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by selecting a Top Site from the Urlbar. +add_task(async function test_topsites_urlbar() { + // Disable suggestions to avoid hitting Amazon servers. + await SpecialPowers.pushPrefEnv({ + set: [[SUGGEST_PREF, false]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Enter search mode by selecting a Top Site from the Urlbar. + await UrlbarTestUtils.promisePopupOpen(window, () => { + if (gURLBar.getAttribute("pageproxystate") == "invalid") { + gURLBar.handleRevert(); + } + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await UrlbarTestUtils.promiseSearchComplete(window); + + let amazonSearch = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + 0 + ); + Assert.equal( + amazonSearch.result.payload.keyword, + "@amazon", + "First result should have the Amazon keyword." + ); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeMouseAtCenter(amazonSearch, {}); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: amazonSearch.result.payload.engine, + entry: "topsites_urlbar", + }); + assertSearchModeScalars("topsites_urlbar", "Amazon"); + await UrlbarTestUtils.exitSearchMode(window); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by selecting a keyword offer result. +add_task(async function test_keywordoffer() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Do a search for "@" + our test alias. It should autofill with a trailing + // space, and the heuristic result should be an autofill result with a keyword + // offer. + let alias = "@" + ENGINE_ALIAS; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: alias, + }); + let keywordOfferResult = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + Assert.equal( + keywordOfferResult.searchParams.keyword, + alias, + "The first result should be a keyword search result with the correct alias." + ); + + // Pick the keyword offer result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "keywordoffer", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("keywordoffer", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by typing an alias. +add_task(async function test_typed() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + // Enter search mode by selecting a keywordoffer result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${ENGINE_ALIAS} `, + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(" "); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "typed", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("typed", "other", 0); + + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by calling the same function called by the Search +// Bookmarks menu item in Library > Bookmarks. +add_task(async function test_bookmarkmenu() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + PlacesCommandHook.searchBookmarks(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + entry: "bookmarkmenu", + }); + assertSearchModeScalars("bookmarkmenu", "bookmarks"); + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by calling the same function called from a History +// menu. +add_task(async function test_historymenu() { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + PlacesCommandHook.searchHistory(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "historymenu", + }); + assertSearchModeScalars("historymenu", "history"); +}); + +// Enters search mode by calling the same function called by the Search Tabs +// menu item in the tab overflow menu. +add_task(async function test_tabmenu() { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + gTabsPanel.searchTabs(); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + entry: "tabmenu", + }); + assertSearchModeScalars("tabmenu", "tabs"); +}); + +// Enters search mode by performing a search handoff on about:privatebrowsing. +// Note that handoff-to-search-mode only occurs when suggestions are disabled +// in the Urlbar. +// NOTE: We don't test handoff on about:home. Running mochitests on about:home +// is quite difficult. This subtest verifies that `handoff` is a valid scalar +// suffix and that a call to UrlbarInput.handoff(value, searchEngine) records +// values in the urlbar.searchmode.handoff scalar. PlacesFeed.test.js verfies that +// about:home handoff makes that exact call. +add_task(async function test_handoff_pbm() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + let tab = win.gBrowser.selectedBrowser; + + await SpecialPowers.spawn(tab, [], async function () { + let btn = content.document.getElementById("search-handoff-button"); + btn.click(); + }); + + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + await new Promise(r => EventUtils.synthesizeKey("f", {}, win, r)); + await searchPromise; + await UrlbarTestUtils.assertSearchMode(win, { + engineName, + entry: "handoff", + }); + assertSearchModeScalars("handoff", "other"); + + await UrlbarTestUtils.exitSearchMode(win); + await UrlbarTestUtils.promisePopupClose(win); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by tapping a search shortcut on the Touch Bar. +add_task(async function test_touchbar() { + if (AppConstants.platform != "macosx") { + return; + } + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_QUERY, + }); + // We have to fake the tap on the Touch Bar since mochitests have no way of + // interacting with the Touch Bar. + TouchBarHelper.insertRestrictionInUrlbar(UrlbarTokenizer.RESTRICT.HISTORY); + await UrlbarTestUtils.assertSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "touchbar", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("touchbar", "history", 0); + BrowserTestUtils.removeTab(tab); +}); + +// Enters search mode by selecting a tab-to-search result. +// Tests that tab-to-search results preview search mode when highlighted. These +// results are worth testing separately since they do not set the +// payload.keyword parameter. +add_task(async function test_tabtosearch() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Do not show the onboarding result for this subtest. + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }); + await PlacesTestUtils.addVisits([`http://${engineDomain}/`]); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the correct engine." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + // Pick the tab-to-search result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "tabtosearch", + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("tabtosearch", "other", 0); + + BrowserTestUtils.removeTab(tab); + + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +// Enters search mode by selecting a tab-to-search onboarding result. +add_task(async function test_tabtosearch_onboard() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + await PlacesTestUtils.addVisits([`http://${engineDomain}/`]); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + fireInputEvent: true, + }); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the correct engine." + ); + Assert.equal( + tabToSearchResult.payload.dynamicType, + "onboardTabToSearch", + "The tab-to-search result is an onboarding result." + ); + await UrlbarTestUtils.assertSearchMode(window, null); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + // Pick the tab-to-search onboarding result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName, + entry: "tabtosearch_onboard", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + assertSearchModeScalars("tabtosearch_onboard", "other", 0); + + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_sponsored_topsites.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_sponsored_topsites.js new file mode 100644 index 0000000000..74d1fdb0db --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_sponsored_topsites.js @@ -0,0 +1,181 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +// This is used for "sendAttributionRequest" +var gHttpServer = null; +var gRequests = []; + +function submitHandler(request, response) { + gRequests.push(request); + response.setStatusLine(request.httpVersion, 200, "Ok"); +} + +// Spy for telemetry sender +let spy; + +add_setup(async function () { + sandbox = sinon.createSandbox(); + spy = sandbox.spy( + PartnerLinkAttribution._pingCentre, + "sendStructuredIngestionPing" + ); + + let topsitesAttribution = Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ); + gHttpServer = new HttpServer(); + gHttpServer.registerPathHandler(`/cid/${topsitesAttribution}`, submitHandler); + gHttpServer.start(-1); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.sponsoredTopSites", true], + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + [ + "browser.partnerlink.attributionURL", + `http://localhost:${gHttpServer.identity.primaryPort}/cid/`, + ], + ], + }); + + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + registerCleanupFunction(async () => { + sandbox.restore(); + await gHttpServer.stop(); + gHttpServer = null; + }); +}); + +add_task(async function send_impression_and_click() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let link = { + label: "test_label", + url: "http://example.com/", + sponsored_position: 1, + sendAttributionRequest: true, + sponsored_tile_id: 42, + sponsored_impression_url: "http://impression.test.com/", + sponsored_click_url: "http://click.test.com/", + }; + // Pin a sponsored TopSite to set up the test fixture + NewTabUtils.pinnedLinks.pin(link, 0); + + await updateTopSites(sites => sites && sites[0] && sites[0].isPinned); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + await UrlbarTestUtils.promiseSearchComplete(window); + + // Select the first result and confirm it. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + result.url, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + Assert.ok( + spy.calledTwice, + "Should send an impression ping and a click ping" + ); + + // Validate the impression ping + let [payload, endpoint] = spy.firstCall.args; + Assert.ok( + endpoint.includes(CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_IMPRESSION), + "Should set the endpoint for TopSites impression" + ); + Assert.ok(!!payload.context_id, "Should set the context_id"); + Assert.equal(payload.advertiser, "test_label", "Should set the advertiser"); + Assert.equal( + payload.reporting_url, + "http://impression.test.com/", + "Should set the impression reporting URL" + ); + Assert.equal(payload.tile_id, 42, "Should set the tile_id"); + Assert.equal(payload.position, 1, "Should set the position"); + + // Validate the click ping + [payload, endpoint] = spy.secondCall.args; + Assert.ok( + endpoint.includes(CONTEXTUAL_SERVICES_PING_TYPES.TOPSITES_SELECTION), + "Should set the endpoint for TopSites click" + ); + Assert.ok(!!payload.context_id, "Should set the context_id"); + Assert.equal( + payload.reporting_url, + "http://click.test.com/", + "Should set the click reporting URL" + ); + Assert.equal(payload.tile_id, 42, "Should set the tile_id"); + Assert.equal(payload.position, 1, "Should set the position"); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + NewTabUtils.pinnedLinks.unpin(link); + }); +}); + +add_task(async function zero_ping() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + spy.resetHistory(); + + // Reload the TopSites + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + await UrlbarTestUtils.promiseSearchComplete(window); + + // Select the first result and confirm it. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + result.url, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + Assert.ok( + spy.notCalled, + "Should not send any ping if there is no sponsored Top Site" + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js new file mode 100644 index 0000000000..35607d2f94 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js @@ -0,0 +1,416 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests telemetry for tabtosearch results. + * NB: This file does not test the search mode `entry` field for tab-to-search + * results. That is tested in browser_UsageTelemetry_urlbar_searchmode.js. + */ + +"use strict"; + +const ENGINE_NAME = "MozSearch"; +const ENGINE_DOMAIN = "example.com"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Checks to see if the second result in the Urlbar is a tab-to-search result + * with the correct engine. + * + * @param {string} engineName + * The expected engine name. + * @param {boolean} [isOnboarding] + * If true, expects the tab-to-search result to be an onbarding result. + */ +async function checkForTabToSearchResult(engineName, isOnboarding) { + Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Popup should be open."); + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + engineName, + "The tab-to-search result is for the first engine." + ); + if (isOnboarding) { + Assert.equal( + tabToSearchResult.payload.dynamicType, + "onboardTabToSearch", + "The tab-to-search result is an onboarding result." + ); + } else { + Assert.ok( + !tabToSearchResult.payload.dynamicType, + "The tab-to-search result should not be an onboarding result." + ); + } +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await SearchTestUtils.installSearchExtension({ + name: ENGINE_NAME, + search_url: `https://${ENGINE_DOMAIN}/`, + }); + + // Reset the enginesShown sets in case a previous test showed a tab-to-search + // result but did not end its engagement. + UrlbarProviderTabToSearch.enginesShown.regular.clear(); + UrlbarProviderTabToSearch.enginesShown.onboarding.clear(); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + const histograms = snapshotHistograms(); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${ENGINE_DOMAIN}/`]); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: ENGINE_DOMAIN.slice(0, 4), + fireInputEvent: true, + }); + + let tabToSearchResult = ( + await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1) + ).result; + Assert.equal( + tabToSearchResult.providerName, + "TabToSearch", + "The second result is a tab-to-search result." + ); + Assert.equal( + tabToSearchResult.payload.engine, + ENGINE_NAME, + "The tab-to-search result is for the correct engine." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "Sanity check: The second result is selected." + ); + + // Select the tab-to-search result. + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await searchPromise; + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: ENGINE_NAME, + entry: "tabtosearch", + }); + + assertTelemetryResults( + histograms, + "tabtosearch", + 1, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + await PlacesUtils.history.clear(); + }); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); +}); + +add_task(async function impressions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + await impressions_test(false); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function onboarding_impressions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + await impressions_test(true); + await SpecialPowers.popPrefEnv(); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; +}); + +async function impressions_test(isOnboarding) { + await BrowserTestUtils.withNewTab("about:blank", async browser => { + const firstEngineHost = "example"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: `${ENGINE_NAME}2`, + search_url: `https://${firstEngineHost}-2.com/`, + }, + { skipUnload: true } + ); + + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${firstEngineHost}-2.com`]); + await PlacesTestUtils.addVisits([`https://${ENGINE_DOMAIN}/`]); + } + + // First do multiple searches for substrings of firstEngineHost. The view + // should show the same tab-to-search onboarding result the entire time, so + // we should not continue to increment urlbar.tips. + for (let i = 1; i < firstEngineHost.length; i++) { + info( + `Search for "${firstEngineHost.slice( + 0, + i + )}". Only record one impression for this sequence.` + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost.slice(0, i), + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + } + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 1 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + // "other" is recorded as the engine name because we're not using a built-in engine. + "other", + 1 + ); + + info("Type through autofill to second engine hostname. Record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + // Since the user typed past the autofill for the first engine, we showed a + // different onboarding result and now we increment + // tabtosearch_onboard-shown. + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 3 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 3 + ); + + info("Make a typo and return to autofill. Do not record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-3`, + fireInputEvent: true, + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "We are not showing a tab-to-search result." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-2`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 4 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 4 + ); + + info( + "Cancel then restart autofill. Continue to show the tab-to-search result." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-2`, + fireInputEvent: true, + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Backspace"); + await searchPromise; + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + // Type the "." from `example-2.com`. + EventUtils.synthesizeKey("."); + await searchPromise; + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 5 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + // "other" is recorded as the engine name because we're not using a built-in engine. + "other", + 5 + ); + + // See javadoc for UrlbarProviderTabToSearch.onEngagement for discussion + // about retained results. + info("Reopen the result set with retained results. Record impression."); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + await checkForTabToSearchResult(`${ENGINE_NAME}2`, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + scalars = TelemetryTestUtils.getProcessScalars("parent", true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 6 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 6 + ); + + info( + "Open a result page and then autofill engine host. Record impression." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + // Press enter on the heuristic result so we visit example.com without + // doing an additional search. + let loadPromise = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + // Click the Urlbar and type to simulate what a user would actually do. If + // we use promiseAutocompleteResultPopup, no query would be made between + // this one and the previous tab-to-search query. Thus + // `onboardingEnginesShown` would not be cleared. This would not happen + // in real-world usage. + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey(firstEngineHost.slice(0, 4)); + await searchPromise; + await checkForTabToSearchResult(ENGINE_NAME, isOnboarding); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + // We clear the scalar this time. + scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "urlbar.tips", + isOnboarding ? "tabtosearch_onboard-shown" : "tabtosearch-shown", + 8 + ); + TelemetryTestUtils.assertKeyedScalar( + scalars, + isOnboarding + ? "urlbar.tabtosearch.impressions_onboarding" + : "urlbar.tabtosearch.impressions", + "other", + 8 + ); + + await PlacesUtils.history.clear(); + await extension.unload(); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js new file mode 100644 index 0000000000..c234bc3ed8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for tip results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test() { + // Add a restricting provider that returns a preselected heuristic tip result. + let provider = new TipProvider([ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + helpUrl: "https://example.com/", + type: "test", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "https://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + { heuristic: true } + ), + ]); + UrlbarProvidersManager.registerProvider(provider); + + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + // Show the view and press enter to select the tip. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "test", + fireInputEvent: true, + }); + EventUtils.synthesizeKey("KEY_Enter"); + + assertTelemetryResults( + histograms, + "tip", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + UrlbarProvidersManager.unregisterProvider(provider); + BrowserTestUtils.removeTab(tab); +}); + +/** + * A test URLBar provider. + */ +class TipProvider extends UrlbarProvider { + constructor(results) { + super(); + this._results = results; + } + get name() { + return "TestProviderTip"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + return true; + } + getPriority(context) { + return 1; + } + async startQuery(context, addCallback) { + context.preselected = true; + for (const result of this._results) { + addCallback(this, result); + } + } +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js new file mode 100644 index 0000000000..12adb27caf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js @@ -0,0 +1,136 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry for topsite results. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", +}); + +const EN_US_TOPSITES = + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/"; + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ["browser.urlbar.suggest.quickactions", false], + ], + }); + await updateTopSites( + sites => sites && sites.length == EN_US_TOPSITES.split(",").length + ); +}); + +add_task(async function test() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let sites = AboutNewTab.getTopSites(); + Assert.equal( + sites.length, + 6, + "The test suite browser should have 6 Top Sites." + ); + + const histograms = snapshotHistograms(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + }); + + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + sites.length, + "The number of results should be the same as the number of Top Sites (6)." + ); + // Select the first resultm and confirm it. + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The first result should be selected" + ); + + let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt( + result.url, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + + assertTelemetryResults( + histograms, + "topsite", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.arrowEnterSelection + ); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + }); +}); diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js new file mode 100644 index 0000000000..b331921553 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_zeroPrefix.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/** + * This file tests urlbar telemetry related to the zero-prefix view, i.e., when + * the search string is empty. + */ + +"use strict"; + +const HISTOGRAM_DWELL_TIME = "FX_URLBAR_ZERO_PREFIX_DWELL_TIME_MS"; +const SCALARS = { + ABANDONMENT: "urlbar.zeroprefix.abandonment", + ENGAGEMENT: "urlbar.zeroprefix.engagement", + EXPOSURE: "urlbar.zeroprefix.exposure", +}; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.clearScalars(); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + await updateTopSitesAndAwaitChanged(); +}); + +// zero prefix engagement +add_task(async function engagement() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Finding row with result type URL"); + let foundURLRow = false; + let count = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < count && !foundURLRow; i++) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + let index = UrlbarTestUtils.getSelectedRowIndex(window); + Assert.equal(index, i, "The expected row index should be selected"); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + info(`Checked row at index ${i}, result type is: ${details.type}`); + if (details.type == UrlbarUtils.RESULT_TYPE.URL) { + foundURLRow = true; + } + } + Assert.ok(foundURLRow, "Should have found a row with result type URL"); + + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + + checkScalars({ + [SCALARS.ENGAGEMENT]: 1, + }); + checkAndClearHistogram(dwellHistogram, true); +}); + +// zero prefix abandonment +add_task(async function abandonment() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + // Open and close the view twice. The second time the view will used a cached + // query context and that shouldn't interfere with telemetry. + for (let i = 0; i < 2; i++) { + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + checkScalars({ + [SCALARS.ABANDONMENT]: 1, + }); + dwellHistogram = checkAndClearHistogram(dwellHistogram, true); + } +}); + +// Shows the zero-prefix view, does some searches, then shows it again by doing +// a search for an empty string. +add_task(async function searches() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + info("Show zero prefix"); + await showZeroPrefix(); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for 't'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "t", + }); + checkScalars({}); + dwellHistogram = checkAndClearHistogram(dwellHistogram, true); + + info("Search for 'te'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "te", + }); + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for 't'"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "t", + }); + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); + + info("Search for ''"); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + }); + checkScalars({ + [SCALARS.EXPOSURE]: 1, + }); + checkAndClearHistogram(dwellHistogram, false); + + info("Blur urlbar and close view"); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + checkScalars({ + [SCALARS.ABANDONMENT]: 1, + }); + checkAndClearHistogram(dwellHistogram, true); +}); + +// A zero prefix engagement should not be recorded when the view isn't showing +// zero prefix. +add_task(async function notZeroPrefix_engagement() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); +}); + +// A zero prefix abandonment should not be recorded when the view isn't showing +// zero prefix. +add_task(async function notZeroPrefix_abandonment() { + let dwellHistogram = + TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_DWELL_TIME); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + + checkScalars({}); + checkAndClearHistogram(dwellHistogram, false); +}); + +function checkScalars(expected) { + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + for (let scalar of Object.values(SCALARS)) { + if (expected.hasOwnProperty(scalar)) { + TelemetryTestUtils.assertScalar(scalars, scalar, expected[scalar]); + } else { + Assert.ok( + !scalars.hasOwnProperty(scalar), + "Scalar should not be recorded: " + scalar + ); + } + } +} + +function checkAndClearHistogram(histogram, expected) { + if (expected) { + Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Dwell histogram should be updated" + ); + } else { + Assert.strictEqual( + histogram.snapshot().sum, + 0, + "Dwell histogram should not be updated" + ); + } + + return TelemetryTestUtils.getAndClearHistogram(histogram.name()); +} + +async function showZeroPrefix() { + let { promise, cleanup } = waitForQueryFinished(); + await SimpleTest.promiseFocus(window); + await UrlbarTestUtils.promisePopupOpen(window, () => + document.getElementById("Browser:OpenLocation").doCommand() + ); + await promise; + cleanup(); + + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 0, + "There should be at least one row in the zero prefix view" + ); +} + +/** + * Returns a promise that's resolved on the next `onQueryFinished()`. It's + * important to wait for `onQueryFinished()` because that's when the view checks + * whether it's showing zero prefix. + * + * @returns {object} + * An object with the following properties: + * {Promise} promise + * Resolved when `onQueryFinished()` is called. + * {Function} cleanup + * This should be called to remove the listener. + */ +function waitForQueryFinished() { + let deferred = PromiseUtils.defer(); + let listener = { + onQueryFinished: () => deferred.resolve(), + }; + gURLBar.controller.addQueryListener(listener); + + return { + promise: deferred.promise, + cleanup() { + gURLBar.controller.removeQueryListener(listener); + }, + }; +} + +async function updateTopSitesAndAwaitChanged() { + let url = "http://mochi.test:8888/topsite"; + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length); + await changedPromise; +} diff --git a/browser/components/urlbar/tests/browser/browser_userTypedValue.js b/browser/components/urlbar/tests/browser/browser_userTypedValue.js new file mode 100644 index 0000000000..8319b37962 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_userTypedValue.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + const URI = TEST_BASE_URL + "file_userTypedValue.html"; + window.browserDOMWindow.openURI( + makeURI(URI), + null, + Ci.nsIBrowserDOMWindow.OPEN_NEWTAB, + Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL, + Services.scriptSecurityManager.getSystemPrincipal() + ); + + is(gBrowser.userTypedValue, URI, "userTypedValue matches test URI"); + is(gURLBar.value, URI, "location bar value matches test URI"); + + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + is( + gBrowser.userTypedValue, + URI, + "userTypedValue matches test URI after switching tabs" + ); + is( + gURLBar.value, + URI, + "location bar value matches test URI after switching tabs" + ); + + waitForExplicitFinish(); + BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser).then(() => { + is( + gBrowser.userTypedValue, + null, + "userTypedValue is null as the page has loaded" + ); + is( + gURLBar.value, + URI, + "location bar value matches test URI as the page has loaded" + ); + + gBrowser.removeCurrentTab({ skipPermitUnload: true }); + finish(); + }); +} diff --git a/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js b/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js new file mode 100644 index 0000000000..9d3e922692 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_valueOnTabSwitch.js @@ -0,0 +1,166 @@ +/* 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/. */ + +/** + * This tests for the correct URL being displayed in the URL bar after switching + * tabs which are in different states (e.g. deleted, partially deleted). + */ + +"use strict"; + +const TEST_URL = `${TEST_BASE_URL}dummy_page.html`; + +add_task(async function () { + // autofill may conflict with the test scope, by filling missing parts of + // the url due to autoOpen. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + let charsToDelete, + deletedURLTab, + fullURLTab, + partialURLTab, + testPartialURL, + testURL; + + charsToDelete = 5; + deletedURLTab = BrowserTestUtils.addTab(gBrowser); + fullURLTab = BrowserTestUtils.addTab(gBrowser); + partialURLTab = BrowserTestUtils.addTab(gBrowser); + testURL = TEST_URL; + + let loaded1 = BrowserTestUtils.browserLoaded( + deletedURLTab.linkedBrowser, + false, + testURL + ); + let loaded2 = BrowserTestUtils.browserLoaded( + fullURLTab.linkedBrowser, + false, + testURL + ); + let loaded3 = BrowserTestUtils.browserLoaded( + partialURLTab.linkedBrowser, + false, + testURL + ); + BrowserTestUtils.loadURIString(deletedURLTab.linkedBrowser, testURL); + BrowserTestUtils.loadURIString(fullURLTab.linkedBrowser, testURL); + BrowserTestUtils.loadURIString(partialURLTab.linkedBrowser, testURL); + await Promise.all([loaded1, loaded2, loaded3]); + + testURL = BrowserUIUtils.trimURL(testURL); + testPartialURL = testURL.substr(0, testURL.length - charsToDelete); + + function cleanUp() { + gBrowser.removeTab(fullURLTab); + gBrowser.removeTab(partialURLTab); + gBrowser.removeTab(deletedURLTab); + } + + async function cycleTabs() { + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to fullURLTab" + ); + + await BrowserTestUtils.switchTab(gBrowser, partialURLTab); + is( + gURLBar.value, + testPartialURL, + "gURLBar.value should be testPartialURL after switching back to partialURLTab" + ); + await BrowserTestUtils.switchTab(gBrowser, deletedURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to deletedURLTab" + ); + + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after switching back to fullURLTab" + ); + } + + function urlbarBackspace(removeAll) { + return new Promise((resolve, reject) => { + gBrowser.selectedBrowser.focus(); + gURLBar.addEventListener( + "input", + function () { + resolve(); + }, + { once: true } + ); + gURLBar.focus(); + if (removeAll) { + gURLBar.select(); + } else { + gURLBar.selectionStart = gURLBar.selectionEnd = gURLBar.value.length; + } + EventUtils.synthesizeKey("KEY_Backspace"); + }); + } + + async function prepareDeletedURLTab() { + await BrowserTestUtils.switchTab(gBrowser, deletedURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to deletedURLTab" + ); + + // simulate the user removing the whole url from the location bar + await urlbarBackspace(true); + is(gURLBar.value, "", 'gURLBar.value should be "" (just set)'); + } + + async function prepareFullURLTab() { + await BrowserTestUtils.switchTab(gBrowser, fullURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to fullURLTab" + ); + } + + async function preparePartialURLTab() { + await BrowserTestUtils.switchTab(gBrowser, partialURLTab); + is( + gURLBar.value, + testURL, + "gURLBar.value should be testURL after initial switch to partialURLTab" + ); + + // simulate the user removing part of the url from the location bar + let deleted = 0; + while (deleted < charsToDelete) { + await urlbarBackspace(false); + deleted++; + } + + is( + gURLBar.value, + testPartialURL, + "gURLBar.value should be testPartialURL (just set)" + ); + } + + // prepare the three tabs required by this test + + // First tab + await prepareFullURLTab(); + await preparePartialURLTab(); + await prepareDeletedURLTab(); + + // now cycle the tabs and make sure everything looks good + await cycleTabs(); + cleanUp(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js b/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js new file mode 100644 index 0000000000..f7a2721093 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_emptyResultSet.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the view results are cleared and the view is closed, when an empty +// result set arrives after a non-empty one. + +add_task(async function () { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) > 0, + `There should be some results in the view.` + ); + Assert.ok(gURLBar.view.isOpen, `The view should be open.`); + + // Register an high priority empty result provider. + let provider = new UrlbarTestUtils.TestProvider({ + results: [], + priority: 999, + }); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(async function () { + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + }); + Assert.ok( + UrlbarTestUtils.getResultCount(window) == 0, + `There should be no results in the view.` + ); + Assert.ok(!gURLBar.view.isOpen, `The view should have been closed.`); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js b/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js new file mode 100644 index 0000000000..c4053eaed7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_resultDisplay.js @@ -0,0 +1,354 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that a result has the various elements displayed in the URL bar as + * we expect them to be. + */ + +add_setup(async function () { + await PlacesUtils.history.clear(); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.prefs.clearUserPref("browser.urlbar.trimURLs"); + }); +}); + +async function testResult(input, expected, index = 1) { + const ESCAPED_URL = encodeURI(input.url); + + await PlacesUtils.history.clear(); + if (index > 0) { + await PlacesTestUtils.addVisits({ + uri: input.url, + title: input.title, + }); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input.query, + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.url, ESCAPED_URL, "Should have the correct url to load"); + Assert.equal( + result.displayed.url, + expected.displayedUrl, + "Should have the correct displayed url" + ); + Assert.equal( + result.displayed.title, + input.title, + "Should have the expected title" + ); + Assert.equal( + result.displayed.typeIcon, + "none", + "Should not have a type icon" + ); + if (index > 0) { + Assert.equal( + result.image, + `page-icon:${ESCAPED_URL}`, + "Should have the correct favicon" + ); + } + + assertDisplayedHighlights( + "title", + result.element.title, + expected.highlightedTitle + ); + + assertDisplayedHighlights("url", result.element.url, expected.highlightedUrl); +} + +function assertDisplayedHighlights(elementName, element, expectedResults) { + Assert.equal( + element.childNodes.length, + expectedResults.length, + `Should have the correct number of child nodes for ${elementName}` + ); + + for (let i = 0; i < element.childNodes.length; i++) { + let child = element.childNodes[i]; + Assert.equal( + child.textContent, + expectedResults[i][0], + `Should have the correct text for the ${i} part of the ${elementName}` + ); + Assert.equal( + child.nodeName, + expectedResults[i][1] ? "strong" : "#text", + `Should have the correct text/strong status for the ${i} part of the ${elementName}` + ); + } +} + +add_task(async function test_url_result() { + await testResult( + { + query: "\u6e2C\u8a66", + title: "The \u6e2C\u8a66 URL", + url: "https://example.com/\u6e2C\u8a66test", + }, + { + displayedUrl: "example.com/\u6e2C\u8a66test", + highlightedTitle: [ + ["The ", false], + ["\u6e2C\u8a66", true], + [" URL", false], + ], + highlightedUrl: [ + ["example.com/", false], + ["\u6e2C\u8a66", true], + ["test", false], + ], + } + ); +}); + +add_task(async function test_url_result_no_path() { + await testResult( + { + query: "ample", + title: "The Title", + url: "https://example.com/", + }, + { + displayedUrl: "example.com", + highlightedTitle: [["The Title", false]], + highlightedUrl: [ + ["ex", false], + ["ample", true], + [".com", false], + ], + } + ); +}); + +add_task(async function test_url_result_www() { + await testResult( + { + query: "ample", + title: "The Title", + url: "https://www.example.com/", + }, + { + displayedUrl: "example.com", + highlightedTitle: [["The Title", false]], + highlightedUrl: [ + ["ex", false], + ["ample", true], + [".com", false], + ], + } + ); +}); + +add_task(async function test_url_result_no_trimming() { + Services.prefs.setBoolPref("browser.urlbar.trimURLs", false); + + await testResult( + { + query: "\u6e2C\u8a66", + title: "The \u6e2C\u8a66 URL", + url: "http://example.com/\u6e2C\u8a66test", + }, + { + displayedUrl: "http://example.com/\u6e2C\u8a66test", + highlightedTitle: [ + ["The ", false], + ["\u6e2C\u8a66", true], + [" URL", false], + ], + highlightedUrl: [ + ["http://example.com/", false], + ["\u6e2C\u8a66", true], + ["test", false], + ], + } + ); + + Services.prefs.clearUserPref("browser.urlbar.trimURLs"); +}); + +add_task(async function test_case_insensitive_highlights_1() { + await testResult( + { + query: "exam", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_2() { + await testResult( + { + query: "EXAM", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_3() { + await testResult( + { + query: "eXaM", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_4() { + await testResult( + { + query: "ExAm", + title: "The examPLE URL EXAMple", + url: "https://example.com/ExAm", + }, + { + displayedUrl: "example.com/ExAm", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["EXAM", true], + ["ple", false], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_5() { + await testResult( + { + query: "exam foo", + title: "The examPLE URL foo EXAMple FOO", + url: "https://example.com/ExAm/fOo", + }, + { + displayedUrl: "example.com/ExAm/fOo", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["foo", true], + [" ", false], + ["EXAM", true], + ["ple ", false], + ["FOO", true], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ["/", false], + ["fOo", true], + ], + } + ); +}); + +add_task(async function test_case_insensitive_highlights_6() { + await testResult( + { + query: "EXAM FOO", + title: "The examPLE URL foo EXAMple FOO", + url: "https://example.com/ExAm/fOo", + }, + { + displayedUrl: "example.com/ExAm/fOo", + highlightedTitle: [ + ["The ", false], + ["exam", true], + ["PLE URL ", false], + ["foo", true], + [" ", false], + ["EXAM", true], + ["ple ", false], + ["FOO", true], + ], + highlightedUrl: [ + ["exam", true], + ["ple.com/", false], + ["ExAm", true], + ["/", false], + ["fOo", true], + ], + } + ); +}); + +add_task(async function test_no_highlight_fallback_heuristic_url() { + info("Test unvisited heuristic (fallback provider)"); + await testResult( + { + query: "nonexisting.com", + title: "http://nonexisting.com/", + url: "http://nonexisting.com/", + }, + { + displayedUrl: "", // URL heuristic only has title. + highlightedTitle: [["http://nonexisting.com/", false]], + highlightedUrl: [], + }, + 0 // Test the heuristic result. + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js b/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js new file mode 100644 index 0000000000..0cf56f107d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js @@ -0,0 +1,317 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +function assertElementsDisplayed(details, expected) { + Assert.equal( + details.type, + expected.type, + "Should be displaying a row of the correct type" + ); + Assert.equal( + details.title, + expected.title, + "Should be displaying the correct title" + ); + let separatorVisible = + window.getComputedStyle(details.element.separator).display != "none" && + window.getComputedStyle(details.element.separator).visibility != "collapse"; + Assert.equal( + expected.separator, + separatorVisible, + `Should${expected.separator ? " " : " not "}be displaying a separator` + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + // Disable search suggestions in the urlbar. + ["browser.urlbar.suggest.searches", false], + // Clear historical search suggestions to avoid interference from previous + // tests. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + // Move the mouse away from the results panel, because hovering a result may + // change its aspect (e.g. by showing a " - search with Engine" suffix). + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: gURLBar.inputField, + offsetX: 0, + offsetY: 0, + }); +}); + +add_task(async function test_tab_switch_result() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "about:mozilla", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "about:mozilla", + type: UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + }); + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_search_result() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", true); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "foo", + fireInputEvent: true, + }); + + let index = await UrlbarTestUtils.promiseSuggestionsPresent(window); + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + + // We'll initially display no separator. + assertElementsDisplayed(details, { + separator: false, + title: "foofoo", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + + // Down to select the first search suggestion. + for (let i = index; i > 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + // We should now be displaying one. + assertElementsDisplayed(details, { + separator: true, + title: "foofoo", + type: UrlbarUtils.RESULT_TYPE.SEARCH, + }); + }); + + await PlacesUtils.history.clear(); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +add_task(async function test_url_result() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com", + title: "example", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "example", + type: UrlbarUtils.RESULT_TYPE.URL, + }); + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_keyword_result() { + const TEST_URL = `${TEST_BASE_URL}print_postdata.sjs`; + + await PlacesUtils.keywords.insert({ + keyword: "get", + url: TEST_URL + "?q=%s", + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "get ", + fireInputEvent: true, + }); + + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + // Because only the keyword is typed, we show the bookmark url. + assertElementsDisplayed(details, { + separator: true, + title: TEST_URL + "?q=", + type: UrlbarUtils.RESULT_TYPE.KEYWORD, + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "get test", + fireInputEvent: true, + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + assertElementsDisplayed(details, { + separator: false, + title: "example.com: test", + type: UrlbarUtils.RESULT_TYPE.KEYWORD, + }); + }); +}); + +add_task(async function test_omnibox_result() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: "omniboxtest", + }, + + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + // Just do nothing for this test. + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }, + }); + + await extension.startup(); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "omniboxtest ", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + + assertElementsDisplayed(details, { + separator: true, + title: "Generated extension", + type: UrlbarUtils.RESULT_TYPE.OMNIBOX, + }); + }); + + await extension.unload(); +}); + +add_task(async function test_remote_tab_result() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + // Clear history so that history added by previous tests doesn't mess up this + // test when it selects results in the urlbar. + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + const REMOTE_TAB = { + id: "7cqCr77ptzX3", + type: "client", + lastModified: 1492201200, + name: "zcarter's Nightly on MacBook-Pro-25", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "Test Remote", + url: "http://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = sinon.createSandbox(); + + let originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + let oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + + sandbox + .stub(SyncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + + registerCleanupFunction(async function () { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + assertElementsDisplayed(details, { + separator: true, + title: "Test Remote", + type: UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + }); + }); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js new file mode 100644 index 0000000000..2de8439b58 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_selectionByMouse.js @@ -0,0 +1,607 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test selection on result view by mouse. + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + ], + }); + + UrlbarTestUtils.disableResultMenuAutohide(window); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + UrlbarProviderQuickActions.addAction("test-addons", { + commands: ["test-addons"], + label: "quickactions-addons", + onPick: () => + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:about"), + }); + UrlbarProviderQuickActions.addAction("test-downloads", { + commands: ["test-downloads"], + label: "quickactions-downloads2", + onPick: () => + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "about:downloads" + ), + }); + + registerCleanupFunction(function () { + UrlbarProviderQuickActions.removeAction("test-addons"); + UrlbarProviderQuickActions.removeAction("test-downloads"); + }); +}); + +add_task(async function basic() { + const testData = [ + { + description: "Normal result to quick action button", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + mouseup: ".urlbarView-quickaction-row[data-key=test-downloads]", + expected: "about:downloads", + }, + { + description: "Normal result to out of result", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + mouseup: "body", + expected: false, + }, + { + description: "Quick action button to normal result", + mousedown: ".urlbarView-quickaction-row[data-key=test-addons]", + mouseup: ".urlbarView-row:nth-child(1)", + expected: "https://example.com/?q=test", + }, + { + description: "Quick action button to quick action button", + mousedown: ".urlbarView-quickaction-row[data-key=test-addons]", + mouseup: ".urlbarView-quickaction-row[data-key=test-downloads]", + expected: "about:downloads", + }, + { + description: "Quick action button to out of result", + mousedown: ".urlbarView-quickaction-row[data-key=test-downloads]", + mouseup: "body", + expected: false, + }, + ]; + + for (const { description, mousedown, mouseup, expected } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + + EventUtils.synthesizeMouseAtCenter(upElement, { type: "mouseup" }); + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + Assert.ok( + !upElement.hasAttribute("selected"), + "Mouseup element should not be selected after mouseup" + ); + + if (expected) { + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expected + ); + Assert.ok(true, "Expected page is opened"); + } + }); + } +}); + +add_task(async function outOfBrowser() { + const testData = [ + { + description: "Normal result to out of browser", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + }, + { + description: "Normal result to out of result", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + expected: false, + }, + { + description: "Quick action button to out of browser", + mousedown: ".urlbarView-quickaction-row[data-key=test-addons]", + }, + ]; + + for (const { description, mousedown } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement] = await waitForElements([mousedown]); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + + // Mouseup at out of browser. + EventUtils.synthesizeMouse(document.documentElement, -1, -1, { + type: "mouseup", + }); + + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + }); + } +}); + +add_task(async function withSelectionByKeyboard() { + const testData = [ + { + description: "Select normal result, then click on out of result", + mousedown: "body", + mouseup: "body", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + selectedElementAfterMouseDown: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + actionedPage: false, + }, + }, + { + description: "Select quick action button, then click on out of result", + arrowDown: 1, + mousedown: "body", + mouseup: "body", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-quickaction-row[selected]", + selectedElementAfterMouseDown: + "#urlbar-results .urlbarView-quickaction-row[selected]", + actionedPage: false, + }, + }, + { + description: "Select normal result, then click on about:downloads", + mousedown: ".urlbarView-quickaction-row[data-key=test-downloads]", + mouseup: ".urlbarView-quickaction-row[data-key=test-downloads]", + expected: { + selectedElementByKey: + "#urlbar-results .urlbarView-row > .urlbarView-row-inner[selected]", + selectedElementAfterMouseDown: + ".urlbarView-quickaction-row[data-key=test-downloads]", + actionedPage: "about:downloads", + }, + }, + ]; + + for (const { + description, + arrowDown, + mousedown, + mouseup, + expected, + } of testData) { + info(description); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + if (arrowDown) { + EventUtils.synthesizeKey( + "KEY_ArrowDown", + { repeat: arrowDown }, + window + ); + } + + let [selectedElementByKey] = await waitForElements([ + expected.selectedElementByKey, + ]); + Assert.ok( + selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should be selected after arrow down" + ); + + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + + if ( + expected.selectedElementByKey !== expected.selectedElementAfterMouseDown + ) { + let [selectedElementAfterMouseDown] = await waitForElements([ + expected.selectedElementAfterMouseDown, + ]); + Assert.ok( + selectedElementAfterMouseDown.hasAttribute("selected"), + "selectedElementAfterMouseDown should be selected after mousedown" + ); + Assert.ok( + !selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should not be selected after mousedown" + ); + } + + EventUtils.synthesizeMouseAtCenter(upElement, { + type: "mouseup", + }); + + if (expected.actionedPage) { + Assert.ok( + !selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should not be selected after page starts load" + ); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + expected.actionedPage + ); + Assert.ok(true, "Expected page is opened"); + } else { + Assert.ok( + selectedElementByKey.hasAttribute("selected"), + "selectedElementByKey should remain selected" + ); + } + }); + } +}); + +add_task(async function withDnsFirstForSingleWordsPref() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.fixup.dns_first_for_single_words", true]], + }); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.org/", + title: "example", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "ex", + window, + }); + + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + const target = details.element.action; + EventUtils.synthesizeMouseAtCenter(target, { type: "mousedown" }); + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.org/" + ); + EventUtils.synthesizeMouseAtCenter(target, { type: "mouseup" }); + await onLoaded; + Assert.ok(true, "Expected page is opened"); + + await PlacesUtils.bookmarks.eraseEverything(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function buttons() { + let initialTabUrl = "https://example.com/initial"; + let mainResultUrl = "https://example.com/main"; + let mainResultHelpUrl = "https://example.com/help"; + let otherResultUrl = "https://example.com/other"; + + let searchString = "test"; + + // Add a provider with two results: The first has buttons and the second has a + // URL that should or shouldn't become the input's value when the block button + // in the first result is clicked, depending on the test. + let provider = new UrlbarTestUtils.TestProvider({ + priority: Infinity, + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: mainResultUrl, + helpUrl: mainResultHelpUrl, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: otherResultUrl, + } + ), + ], + }); + + // Implement the provider's `onEngagement()` so it removes the result. + let onEngagementCallCount = 0; + provider.onEngagement = (isPrivate, state, queryContext, details) => { + onEngagementCallCount++; + queryContext.view.controller.removeResult(details.result); + }; + + UrlbarProvidersManager.registerProvider(provider); + + let assertBlockResultCalled = () => { + Assert.equal( + onEngagementCallCount, + 1, + "blockResult() should have been called once" + ); + onEngagementCallCount = 0; + + let rowUrls = []; + let rows = UrlbarTestUtils.getResultsContainer(window).children; + for (let row of rows) { + rowUrls.push(row.result.payload.url); + } + Assert.ok( + !rowUrls.includes(mainResultUrl), + "The main result should not be in the view after blocking it: " + + JSON.stringify(rowUrls) + ); + }; + let assertResultMenuOpen = () => { + Assert.equal( + gURLBar.view.resultMenu.state, + "showing", + "Result menu is showing" + ); + EventUtils.synthesizeKey("KEY_Escape"); + }; + + let testData = [ + { + description: UrlbarPrefs.get("resultMenu") + ? "Menu button to menu button" + : "Block button to block button", + mousedown: UrlbarPrefs.get("resultMenu") + ? ".urlbarView-row:nth-child(1) .urlbarView-button-menu" + : ".urlbarView-row:nth-child(1) .urlbarView-button-block", + afterMouseupCallback: UrlbarPrefs.get("resultMenu") + ? assertResultMenuOpen + : assertBlockResultCalled, + expected: { + mousedownSelected: false, + topSites: { + pageProxyState: "valid", + value: initialTabUrl, + }, + searchString: { + pageProxyState: "invalid", + value: searchString, + }, + }, + }, + { + skip: UrlbarPrefs.get("resultMenu"), + description: "Help button to help button", + mousedown: ".urlbarView-row:nth-child(1) .urlbarView-button-help", + expected: { + mousedownSelected: false, + url: mainResultHelpUrl, + newTab: true, + }, + }, + { + description: UrlbarPrefs.get("resultMenu") + ? "Row-inner to menu button" + : "Row-inner to block button", + mousedown: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + mouseup: UrlbarPrefs.get("resultMenu") + ? ".urlbarView-row:nth-child(1) .urlbarView-button-menu" + : ".urlbarView-row:nth-child(1) .urlbarView-button-block", + afterMouseupCallback: UrlbarPrefs.get("resultMenu") + ? assertResultMenuOpen + : assertBlockResultCalled, + expected: { + mousedownSelected: true, + topSites: { + pageProxyState: "invalid", + value: UrlbarPrefs.get("resultMenu") ? initialTabUrl : otherResultUrl, + }, + searchString: { + pageProxyState: "invalid", + value: UrlbarPrefs.get("resultMenu") ? searchString : otherResultUrl, + }, + }, + }, + { + description: UrlbarPrefs.get("resultMenu") + ? "Menu button to row-inner" + : "Block button to row-inner", + mousedown: UrlbarPrefs.get("resultMenu") + ? ".urlbarView-row:nth-child(1) .urlbarView-button-menu" + : ".urlbarView-row:nth-child(1) .urlbarView-button-block", + mouseup: ".urlbarView-row:nth-child(1) > .urlbarView-row-inner", + expected: { + mousedownSelected: false, + url: mainResultUrl, + newTab: false, + }, + }, + ]; + + for (let showTopSites of [true, false]) { + for (let { + description, + mousedown, + mouseup, + expected, + afterMouseupCallback = null, + skip = false, + } of testData) { + if (skip) { + info( + `Skipping test with showTopSites = ${showTopSites}: ${description}` + ); + continue; + } + info(`Running test with showTopSites = ${showTopSites}: ${description}`); + mouseup ||= mousedown; + + await BrowserTestUtils.withNewTab(initialTabUrl, async () => { + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + "valid", + "Sanity check: pageproxystate should be valid initially" + ); + Assert.equal( + gURLBar.value, + initialTabUrl, + "Sanity check: input.value should be the initial URL initially" + ); + + if (showTopSites) { + // Open the view and show top sites by performing the accel+L command. + await SimpleTest.promiseFocus(window); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + document.getElementById("Browser:OpenLocation").doCommand(); + await searchPromise; + } else { + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + }); + } + + let [downElement, upElement] = await waitForElements([ + mousedown, + mouseup, + ]); + + // Mousedown and check the selection. + EventUtils.synthesizeMouseAtCenter(downElement, { + type: "mousedown", + }); + if (expected.mousedownSelected) { + Assert.ok( + downElement.hasAttribute("selected"), + "Mousedown element should be selected after mousedown" + ); + } else { + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mousedown" + ); + } + + let loadPromise; + if (expected.url) { + loadPromise = expected.newTab + ? BrowserTestUtils.waitForNewTab(gBrowser, expected.url) + : BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + null, + expected.url + ); + } + + // Mouseup and check the selection. + EventUtils.synthesizeMouseAtCenter(upElement, { type: "mouseup" }); + Assert.ok( + !downElement.hasAttribute("selected"), + "Mousedown element should not be selected after mouseup" + ); + Assert.ok( + !upElement.hasAttribute("selected"), + "Mouseup element should not be selected after mouseup" + ); + + // If we expect a URL to load, we're done since the view will close and + // the input value will be set to the URL. + if (loadPromise) { + info("Waiting for URL to load: " + expected.url); + let tab = await loadPromise; + Assert.ok(true, "Expected URL loaded"); + if (expected.newTab) { + BrowserTestUtils.removeTab(tab); + } + return; + } + + if (afterMouseupCallback) { + await afterMouseupCallback(); + } + + let state = showTopSites ? expected.topSites : expected.searchString; + Assert.equal( + gURLBar.getAttribute("pageproxystate"), + state.pageProxyState, + "pageproxystate should be as expected" + ); + Assert.equal( + gURLBar.value, + state.value, + "input.value should be as expected" + ); + }); + } + } + + UrlbarProvidersManager.unregisterProvider(provider); +}); + +async function waitForElements(selectors) { + let elements; + await BrowserTestUtils.waitForCondition(() => { + elements = selectors.map(s => document.querySelector(s)); + return elements.every(e => e && BrowserTestUtils.is_visible(e)); + }, "Waiting for elements to become visible: " + JSON.stringify(selectors)); + return elements; +} diff --git a/browser/components/urlbar/tests/browser/browser_waitForLoadOrTimeout.js b/browser/components/urlbar/tests/browser/browser_waitForLoadOrTimeout.js new file mode 100644 index 0000000000..352e37b9d0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_waitForLoadOrTimeout.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the waitForLoadOrTimeout test helper function in head.js. + */ + +"use strict"; + +add_task(async function load() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + let url = "http://example.com/"; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: url, + }); + + let loadPromise = waitForLoadOrTimeout(); + EventUtils.synthesizeKey("KEY_Enter"); + let loadEvent = await loadPromise; + + Assert.ok(loadEvent, "Page should have loaded before timeout"); + Assert.equal( + loadEvent.target.currentURI.spec, + url, + "example.com should have loaded" + ); + }); +}); + +add_task(async function timeout() { + let loadEvent = await waitForLoadOrTimeout(); + Assert.ok( + !loadEvent, + "No page should have loaded, and timeout should have fired" + ); +}); diff --git a/browser/components/urlbar/tests/browser/browser_whereToOpen.js b/browser/components/urlbar/tests/browser/browser_whereToOpen.js new file mode 100644 index 0000000000..339a20d90e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_whereToOpen.js @@ -0,0 +1,192 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NON_EMPTY_TAB = "example.com/non-empty"; +const EMPTY_TAB = "about:blank"; +const META_KEY = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey"; +const ENTER = new KeyboardEvent("keydown", {}); +const ALT_ENTER = new KeyboardEvent("keydown", { altKey: true }); +const ALTGR_ENTER = new KeyboardEvent("keydown", { modifierAltGraph: true }); +const CLICK = new MouseEvent("click", { button: 0 }); +const META_CLICK = new MouseEvent("click", { button: 0, [META_KEY]: true }); +const MIDDLE_CLICK = new MouseEvent("click", { button: 1 }); + +let old_openintab = Preferences.get("browser.urlbar.openintab"); +registerCleanupFunction(async function () { + Preferences.set("browser.urlbar.openintab", old_openintab); +}); + +add_task(async function openInTab() { + // Open a non-empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + NON_EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: ALT_ENTER, + desc: "Alt+Enter, non-empty tab, default prefs", + }, + { + pref: false, + event: ALTGR_ENTER, + desc: "AltGr+Enter, non-empty tab, default prefs", + }, + { + pref: false, + event: META_CLICK, + desc: "Meta+click, non-empty tab, default prefs", + }, + { + pref: false, + event: MIDDLE_CLICK, + desc: "Middle click, non-empty tab, default prefs", + }, + { pref: true, event: ENTER, desc: "Enter, non-empty tab, openInTab" }, + { + pref: true, + event: CLICK, + desc: "Normal click, non-empty tab, openInTab", + }, + ]) { + info(test.desc); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "tab", "URL would be loaded in a new tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function keepEmptyTab() { + // Open an empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: META_CLICK, + desc: "Meta+click, empty tab, default prefs", + }, + { + pref: false, + event: MIDDLE_CLICK, + desc: "Middle click, empty tab, default prefs", + }, + ]) { + info(test.desc); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "tab", "URL would be loaded in a new tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function reuseEmptyTab() { + // Open an empty tab. + let tab = (gBrowser.selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + EMPTY_TAB + )); + + for (let test of [ + { + pref: false, + event: ALT_ENTER, + desc: "Alt+Enter, empty tab, default prefs", + }, + { + pref: false, + event: ALTGR_ENTER, + desc: "AltGr+Enter, empty tab, default prefs", + }, + { pref: true, event: ENTER, desc: "Enter, empty tab, openInTab" }, + { pref: true, event: CLICK, desc: "Normal click, empty tab, openInTab" }, + ]) { + info(test.desc); + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "current", "New URL would reuse the current empty tab"); + } + + // Clean up. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function openInCurrentTab() { + for (let test of [ + { + pref: false, + url: NON_EMPTY_TAB, + event: ENTER, + desc: "Enter, non-empty tab, default prefs", + }, + { + pref: false, + url: NON_EMPTY_TAB, + event: CLICK, + desc: "Normal click, non-empty tab, default prefs", + }, + { + pref: false, + url: EMPTY_TAB, + event: ENTER, + desc: "Enter, empty tab, default prefs", + }, + { + pref: false, + url: EMPTY_TAB, + event: CLICK, + desc: "Normal click, empty tab, default prefs", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: ALT_ENTER, + desc: "Alt+Enter, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: ALTGR_ENTER, + desc: "AltGr+Enter, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: META_CLICK, + desc: "Meta+click, non-empty tab, openInTab", + }, + { + pref: true, + url: NON_EMPTY_TAB, + event: MIDDLE_CLICK, + desc: "Middle click, non-empty tab, openInTab", + }, + ]) { + info(test.desc); + + // Open a new tab. + let tab = (gBrowser.selectedTab = + await BrowserTestUtils.openNewForegroundTab(gBrowser, test.url)); + + Preferences.set("browser.urlbar.openintab", test.pref); + let where = gURLBar._whereToOpen(test.event); + is(where, "current", "URL would open in the current tab"); + + // Clean up. + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/urlbar/tests/browser/dummy_page.html b/browser/components/urlbar/tests/browser/dummy_page.html new file mode 100644 index 0000000000..1a87e28408 --- /dev/null +++ b/browser/components/urlbar/tests/browser/dummy_page.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +</body> +</html> diff --git a/browser/components/urlbar/tests/browser/dynamicResult0.css b/browser/components/urlbar/tests/browser/dynamicResult0.css new file mode 100644 index 0000000000..328127b594 --- /dev/null +++ b/browser/components/urlbar/tests/browser/dynamicResult0.css @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +#urlbar { + --testDynamicResult0: ok0; +} + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-buttonBox { + display: flex; + align-items: center; + min-height: 32px; +} + +.urlbarView-dynamic-test-text { + flex-grow: 1; + flex-shrink: 1; + padding: 10px; +} + +.urlbarView-dynamic-test-selectable, +.urlbarView-dynamic-test-button1, +.urlbarView-dynamic-test-button2 { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; + margin-inline-end: 10px; +} + +.urlbarView-dynamic-test-selectable[selected], +.urlbarView-dynamic-test-button1[selected], +.urlbarView-dynamic-test-button2[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} diff --git a/browser/components/urlbar/tests/browser/dynamicResult1.css b/browser/components/urlbar/tests/browser/dynamicResult1.css new file mode 100644 index 0000000000..ae43fd3f9a --- /dev/null +++ b/browser/components/urlbar/tests/browser/dynamicResult1.css @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +#urlbar { + --testDynamicResult1: ok1; +} + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-buttonBox { + display: flex; + align-items: center; + min-height: 32px; +} + +.urlbarView-dynamic-test-text { + flex-grow: 1; + flex-shrink: 1; + padding: 10px; +} + +.urlbarView-dynamic-test-selectable, +.urlbarView-dynamic-test-button1, +.urlbarView-dynamic-test-button2 { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; + margin-inline-end: 10px; +} + +.urlbarView-dynamic-test-selectable[selected], +.urlbarView-dynamic-test-button1[selected], +.urlbarView-dynamic-test-button2[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} diff --git a/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html b/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html new file mode 100644 index 0000000000..1f5fea8dcf --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_blank_but_not_blank.html @@ -0,0 +1,2 @@ +<script>var q = "1";</script> +<a href="javascript:q">Click me</a> diff --git a/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html b/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html new file mode 100644 index 0000000000..e02242f6a1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_urlbar_edit_dos.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> +<head> +<title>Try editing the URL bar</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<script> +function dos_hash() { + location.hash = "#"; +} + +function dos_pushState() { + history.pushState({}, "Some title", ""); +} +</script> +</body> +</html> diff --git a/browser/components/urlbar/tests/browser/file_userTypedValue.html b/browser/components/urlbar/tests/browser/file_userTypedValue.html new file mode 100644 index 0000000000..a787b70898 --- /dev/null +++ b/browser/components/urlbar/tests/browser/file_userTypedValue.html @@ -0,0 +1 @@ +<html><body>bug562649</body></html> diff --git a/browser/components/urlbar/tests/browser/head-common.js b/browser/components/urlbar/tests/browser/head-common.js new file mode 100644 index 0000000000..4c41483944 --- /dev/null +++ b/browser/components/urlbar/tests/browser/head-common.js @@ -0,0 +1,156 @@ +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", + UrlbarProvider: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +XPCOMUtils.defineLazyGetter(this, "TEST_BASE_URL", () => + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +XPCOMUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "SearchTestUtils", () => { + const { SearchTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +/** + * Initializes an HTTP Server, and runs a task with it. + * + * @param {object} details {scheme, host, port} + * @param {Function} taskFn The task to run, gets the server as argument. + */ +async function withHttpServer( + details = { scheme: "http", host: "localhost", port: -1 }, + taskFn +) { + let server = new HttpServer(); + let url = `${details.scheme}://${details.host}:${details.port}`; + try { + info(`starting HTTP Server for ${url}`); + try { + server.start(details.port); + details.port = server.identity.primaryPort; + server.identity.setPrimary(details.scheme, details.host, details.port); + } catch (ex) { + throw new Error("We can't launch our http server successfully. " + ex); + } + Assert.ok( + server.identity.has(details.scheme, details.host, details.port), + `${url} is listening.` + ); + try { + await taskFn(server); + } catch (ex) { + throw new Error("Exception in the task function " + ex); + } + } finally { + server.identity.remove(details.scheme, details.host, details.port); + try { + await new Promise(resolve => server.stop(resolve)); + } catch (ex) {} + server = null; + } +} + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +/** + * Asserts a search term is in the url bar and state values are + * what they should be. + * + * @param {string} searchString + * String that should be matched in the url bar. + * @param {object | null} options + * Options for the assertions. + * @param {Window | null} options.window + * Window to use for tests. + * @param {string | null} options.pageProxyState + * The pageproxystate that should be expected. Defaults to "valid". + * @param {string | null} options.userTypedValue + * The userTypedValue that should be expected. Defaults to null. + */ +function assertSearchStringIsInUrlbar( + searchString, + { win = window, pageProxyState = "valid", userTypedValue = null } = {} +) { + Assert.equal( + win.gURLBar.value, + searchString, + `Search string should be the urlbar value.` + ); + Assert.equal( + win.gBrowser.selectedBrowser.searchTerms, + searchString, + `Search terms should match.` + ); + Assert.equal( + win.gBrowser.userTypedValue, + userTypedValue, + "userTypedValue should match." + ); + Assert.equal( + win.gURLBar.getAttribute("pageproxystate"), + pageProxyState, + "Pageproxystate should match." + ); +} diff --git a/browser/components/urlbar/tests/browser/head.js b/browser/components/urlbar/tests/browser/head.js new file mode 100644 index 0000000000..4d381320c9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/head.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the result/url loading functionality of UrlbarController. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + PromptTestUtils: "resource://testing-common/PromptTestUtils.sys.mjs", + ResetProfile: "resource://gre/modules/ResetProfile.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +let sandbox; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +registerCleanupFunction(async () => { + // Ensure the Urlbar popup is always closed at the end of a test, to save having + // to do it within each test. + await UrlbarTestUtils.promisePopupClose(window); +}); + +async function selectAndPaste(str, win = window) { + await SimpleTest.promiseClipboardChange(str, () => { + clipboardHelper.copyString(str); + }); + win.gURLBar.select(); + win.document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); +} + +/** + * Waits for a load in any browser or a timeout, whichever comes first. + * + * @param {window} win + * The top-level browser window to listen in. + * @param {number} timeoutMs + * The timeout in ms. + * @returns {event|null} + * If a load event was detected before the timeout fired, then the event is + * returned. event.target will be the browser in which the load occurred. If + * the timeout fired before a load was detected, null is returned. + */ +async function waitForLoadOrTimeout(win = window, timeoutMs = 1000) { + let event; + let listener; + let timeout; + let eventName = "BrowserTestUtils:ContentEvent:load"; + try { + event = await Promise.race([ + new Promise(resolve => { + listener = resolve; + win.addEventListener(eventName, listener, true); + }), + new Promise(resolve => { + timeout = win.setTimeout(resolve, timeoutMs); + }), + ]); + } finally { + win.removeEventListener(eventName, listener, true); + win.clearTimeout(timeout); + } + return event || null; +} + +/** + * Opens the url bar context menu by synthesizing a click. + * Returns a menu item that is specified by an id. + * + * @param {string} anonid - Identifier of a menu item of the url bar context menu. + * @returns {string} - The element that has the corresponding identifier. + */ +async function promiseContextualMenuitem(anonid) { + let textBox = gURLBar.querySelector("moz-input-box"); + let cxmenu = textBox.menupopup; + let cxmenuPromise = BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { + type: "contextmenu", + button: 2, + }); + await cxmenuPromise; + return textBox.getMenuItem(anonid); +} + +/** + * Puts all CustomizableUI widgetry back to their default locations, and + * then fires the `aftercustomization` toolbox event so that UrlbarInput + * knows to reinitialize itself. + * + * @param {window} [win=window] + * The top-level browser window to fire the `aftercustomization` event in. + */ +function resetCUIAndReinitUrlbarInput(win = window) { + CustomizableUI.reset(); + CustomizableUI.dispatchToolboxEvent("aftercustomization", {}, win); +} diff --git a/browser/components/urlbar/tests/browser/moz.png b/browser/components/urlbar/tests/browser/moz.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/browser/components/urlbar/tests/browser/moz.png diff --git a/browser/components/urlbar/tests/browser/print_postdata.sjs b/browser/components/urlbar/tests/browser/print_postdata.sjs new file mode 100644 index 0000000000..5884a1d598 --- /dev/null +++ b/browser/components/urlbar/tests/browser/print_postdata.sjs @@ -0,0 +1,25 @@ +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + if (request.method == "GET") { + response.write(request.queryString); + } else { + let body = new BinaryInputStream(request.bodyInputStream); + + let avail; + let bytes = []; + + while ((avail = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(avail)); + } + + let data = String.fromCharCode.apply(null, bytes); + response.bodyOutputStream.write(data, data.length); + } +} diff --git a/browser/components/urlbar/tests/browser/redirect_error.sjs b/browser/components/urlbar/tests/browser/redirect_error.sjs new file mode 100644 index 0000000000..a3937b0e7a --- /dev/null +++ b/browser/components/urlbar/tests/browser/redirect_error.sjs @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +const REDIRECT_TO = "https://www.bank1.com/"; // Bad-cert host. + +function handleRequest(aRequest, aResponse) { + // Set HTTP Status + aResponse.setStatusLine(aRequest.httpVersion, 301, "Moved Permanently"); + + // Set redirect URI, mirroring the hash value. + let hash = /\#.+/.test(aRequest.path) + ? "#" + aRequest.path.split("#")[1] + : ""; + aResponse.setHeader("Location", REDIRECT_TO + hash); +} diff --git a/browser/components/urlbar/tests/browser/redirect_to.sjs b/browser/components/urlbar/tests/browser/redirect_to.sjs new file mode 100644 index 0000000000..b52ebdc63e --- /dev/null +++ b/browser/components/urlbar/tests/browser/redirect_to.sjs @@ -0,0 +1,9 @@ +"use strict"; + +function handleRequest(request, response) { + // redirect_to.sjs?ctxmenu-image.png + // redirects to : ctxmenu-image.png + const redirectUrl = request.queryString; + response.setStatusLine(request.httpVersion, "302", "Found"); + response.setHeader("Location", redirectUrl, false); +} diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..145392fcf2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + let timeout = parseInt(params.timeout); + if (timeout) { + // Write the response after a timeout. + resp.processAsync(); + gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + gTimer.init( + () => { + writeResponse(params, resp); + resp.finish(); + }, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + return; + } + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echo back the search string with "foo" and "bar" appended. + let suffixes = ["foo", "bar"]; + if (params.count) { + // Add more suffixes. + let serial = 0; + while (suffixes.length < params.count) { + suffixes.push(++serial); + } + } + let data = [params.query, suffixes.map(s => params.query + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..142c91849c --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml new file mode 100644 index 0000000000..565aaf2bc0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine2.xml @@ -0,0 +1,13 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_searchSuggestionEngine2 searchSuggestionEngine2.xml</ShortName> +<!-- Redirect the actual search request to the test-server because of proxy restriction --> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?{searchTerms}"/> +<!-- Redirect speculative connect to a local http server we run for this test --> +<Url type="text/html" method="GET" template="http://localhost:20709/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml new file mode 100644 index 0000000000..7e77e32029 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngineMany.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_searchSuggestionEngineMany searchSuggestionEngineMany.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?{searchTerms}&count=10"/> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml b/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml new file mode 100644 index 0000000000..e7214e65cc --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngineSlow.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>searchSuggestionEngineSlow.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?query={searchTerms}&timeout=3000"/> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/slow-page.sjs b/browser/components/urlbar/tests/browser/slow-page.sjs new file mode 100644 index 0000000000..ce9a759744 --- /dev/null +++ b/browser/components/urlbar/tests/browser/slow-page.sjs @@ -0,0 +1,23 @@ +"use strict"; + +let timer; + +const DELAY_MS = 5000; +function handleRequest(request, response) { + if (request.queryString.endsWith("faster")) { + response.setHeader("Content-Type", "text/html", false); + response.write("<body>Not so slow!</body>"); + return; + } + response.processAsync(); + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + response.setHeader("Content-Type", "text/html", false); + response.write("<body>This is a slow loading page.</body>"); + response.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml new file mode 100644 index 0000000000..8ed4fef6f1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_urlbar_telemetry urlbarTelemetrySearchSuggestions.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/urlbarTelemetrySearchSuggestions.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css b/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css new file mode 100644 index 0000000000..e81052522f --- /dev/null +++ b/browser/components/urlbar/tests/browser/urlbarTelemetryUrlbarDynamic.css @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +.urlbarView-row[dynamicType=test] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-test-button { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; +} + +.urlbarView-dynamic-test-button[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} + +.urlbarView-dynamic-test-button:hover { + color: white; + background-color: var(--urlbarView-primary-button-background-hover); +} + +.urlbarView-dynamic-test-button:active { + color: white; + background-color: var(--urlbarView-primary-button-background-active); +} + +.urlbarView-dynamic-test-buttonSpacer { + flex-basis: 48px; + flex-grow: 1; + flex-shrink: 1; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser.ini b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.ini new file mode 100644 index 0000000000..762173562d --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.ini @@ -0,0 +1,52 @@ +# 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/. + +[DEFAULT] +support-files = + head.js + head-exposure.js + head-groups.js + head-interaction.js + head-n_chars_n_words.js + head-sap.js + head-search_mode.js + ../../browser-tips/head.js + ../../ext/browser/head.js + ../../ext/api.js + ../../ext/schema.json +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure + +[browser_glean_telemetry_abandonment_groups.js] +[browser_glean_telemetry_abandonment_interaction.js] +[browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js] +[browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js] +[browser_glean_telemetry_abandonment_n_chars_n_words.js] +[browser_glean_telemetry_abandonment_sap.js] +[browser_glean_telemetry_abandonment_search_mode.js] +[browser_glean_telemetry_abandonment_tips.js] +[browser_glean_telemetry_engagement_edge_cases.js] +[browser_glean_telemetry_engagement_groups.js] +[browser_glean_telemetry_engagement_interaction.js] +[browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js] +[browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js] +[browser_glean_telemetry_engagement_n_chars_n_words.js] +[browser_glean_telemetry_engagement_sap.js] +[browser_glean_telemetry_engagement_search_mode.js] +[browser_glean_telemetry_engagement_selected_result.js] +support-files = + ../../../../search/test/browser/trendingSuggestionEngine.sjs +[browser_glean_telemetry_engagement_tips.js] +[browser_glean_telemetry_engagement_type.js] +[browser_glean_telemetry_exposure.js] +[browser_glean_telemetry_impression_groups.js] +[browser_glean_telemetry_impression_interaction.js] +[browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js] +[browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js] +[browser_glean_telemetry_impression_n_chars_n_words.js] +[browser_glean_telemetry_impression_preferences.js] +[browser_glean_telemetry_impression_sap.js] +[browser_glean_telemetry_impression_search_mode.js] +[browser_glean_telemetry_impression_timing.js] +[browser_glean_telemetry_record_preferences.js] diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js new file mode 100644 index 0000000000..6341a21a1a --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_groups.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - groups +// - results +// - n_results + +add_setup(async function () { + await initGroupTest(); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,rs_adm_sponsored,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggest", + results: "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([ + { + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js new file mode 100644 index 0000000000..0462833008 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - interaction + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "pasted" }]), + }); +}); + +add_task(async function topsite_search() { + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1804010 + // assertAbandonmentTelemetry([{ interaction: "topsite_search" }]); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..68799544b0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test abandonment telemetry with persisted search terms disabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => doBlur(), + assert: expected => assertAbandonmentTelemetry([{ interaction: expected }]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..f0a217805f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test abandonment telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([{ interaction: "persisted_search_terms" }]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => doBlur(), + assert: expected => assertAbandonmentTelemetry([{ interaction: expected }]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => doBlur(), + assert: expected => + assertAbandonmentTelemetry([ + { interaction: "persisted_search_terms_restarted" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js new file mode 100644 index 0000000000..7427db8cbf --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_n_chars_n_words.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => doBlur(), + assert: nChars => assertAbandonmentTelemetry([{ n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => doBlur(), + assert: nChars => assertAbandonmentTelemetry([{ n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => doBlur(), + assert: nWords => assertAbandonmentTelemetry([{ n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => doBlur(), + assert: nWords => assertAbandonmentTelemetry([{ n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js new file mode 100644 index 0000000000..3d0af65379 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_sap.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar_newtab() { + await doUrlbarNewTabTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar_newtab" }]), + }); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar" }]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js new file mode 100644 index 0000000000..7edcc47a30 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_search_mode.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of abandonment telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => doBlur(), + assert: () => + assertAbandonmentTelemetry([{ search_mode: "search_engine" }]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "bookmarks" }]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "tabs" }]), + }); +}); + +add_task(async function actions() { + await doActionsTest({ + trigger: () => doBlur(), + assert: () => assertAbandonmentTelemetry([{ search_mode: "actions" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js new file mode 100644 index 0000000000..f773b0fb28 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_abandonment_tips.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for abandonment telemetry for tips using Glean. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser-tips/head.js", + this +); + +add_setup(async function () { + Services.fog.setMetricsFeatureConfig( + JSON.stringify({ "urlbar.abandonment": false }) + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.searchTips.test.ignoreShowLimits", true], + ["browser.urlbar.showSearchTerms.featureGate", true], + ], + }); + const engine = await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml", + }); + const originalDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 0); + + registerCleanupFunction(async function () { + Services.fog.setMetricsFeatureConfig("{}"); + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + resetSearchTipsProvider(); + }); +}); + +add_task(async function tip_persist() { + await doTest(async browser => { + await showPersistSearchTip("test"); + gURLBar.focus(); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + + assertAbandonmentTelemetry([{ results: "tip_persist" }]); + }); +}); + +add_task(async function mouse_down_with_tip() { + await doTest(async browser => { + await showPersistSearchTip("test"); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeMouseAtCenter(browser, {}); + }); + + assertAbandonmentTelemetry([{ results: "tip_persist" }]); + }); +}); + +add_task(async function mouse_down_without_tip() { + await doTest(async browser => { + EventUtils.synthesizeMouseAtCenter(browser, {}); + + assertAbandonmentTelemetry([]); + }); +}); + +async function showPersistSearchTip(word) { + await openPopup(word); + await doEnter(); + await BrowserTestUtils.waitForCondition(async () => { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.payload?.type === "searchTip_persist") { + return true; + } + } + return false; + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js new file mode 100644 index 0000000000..d7b2e775b8 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_edge_cases.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test edge cases for engagement. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/ext/browser/head.js", + this +); + +add_setup(async function () { + await setup(); +}); + +/** + * UrlbarProvider that does not add any result. + */ +class NoResponseTestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ name: "TestProviderNoResponse ", results: [] }); + this.#deferred = PromiseUtils.defer(); + } + + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, addCallback) { + await this.#deferred.promise; + } + + done() { + this.#deferred.resolve(); + } + + #deferred = null; +} +const noResponseProvider = new NoResponseTestProvider(); + +/** + * UrlbarProvider that adds a heuristic result immediately as usual. + */ +class AnotherHeuristicProvider extends UrlbarTestUtils.TestProvider { + constructor({ results }) { + super({ name: "TestProviderAnotherHeuristic ", results }); + this.#deferred = PromiseUtils.defer(); + } + + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, addCallback) { + for (const result of this._results) { + addCallback(this, result); + } + + this.#deferred.resolve(context); + } + + onQueryStarted() { + return this.#deferred.promise; + } + + #deferred = null; +} +const anotherHeuristicProvider = new AnotherHeuristicProvider({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "https://example.com/immediate" } + ), + { heuristic: true } + ), + ], +}); + +add_task(async function engagement_before_showing_results() { + await SpecialPowers.pushPrefEnv({ + // Avoid showing search tip. + set: [["browser.urlbar.tipShownCount.searchTip_onboard", 999]], + }); + + // Update chunkResultsDelayMs to delay the call to notifyResults. + const originalChuldResultDelayMs = + UrlbarProvidersManager._chunkResultsDelayMs; + UrlbarProvidersManager._chunkResultsDelayMs = 1000000; + + // Add a provider that waits forever in startQuery() to avoid fireing + // heuristicProviderTimer. + UrlbarProvidersManager.registerProvider(noResponseProvider); + + // Add a provider that add a result immediately as usual. + UrlbarProvidersManager.registerProvider(anotherHeuristicProvider); + + const cleanup = () => { + UrlbarProvidersManager.unregisterProvider(noResponseProvider); + UrlbarProvidersManager.unregisterProvider(anotherHeuristicProvider); + UrlbarProvidersManager._chunkResultsDelayMs = originalChuldResultDelayMs; + }; + registerCleanupFunction(cleanup); + + await doTest(async browser => { + // Try to show the results. + await UrlbarTestUtils.inputIntoURLBar(window, "exam"); + + // Wait until starting the query and filling expected results. + const context = await anotherHeuristicProvider.onQueryStarted(); + const query = UrlbarProvidersManager.queries.get(context); + await BrowserTestUtils.waitForCondition( + () => + query.unsortedResults.some( + r => r.providerName === "HeuristicFallback" + ) && + query.unsortedResults.some( + r => r.providerName === anotherHeuristicProvider.name + ) + ); + + // Type Enter key before showing any results. + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "", + groups: "", + }, + ]); + + // Clear the pending query. + noResponseProvider.done(); + }); + + cleanup(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function engagement_after_closing_results() { + const TRIGGERS = [ + () => EventUtils.synthesizeKey("KEY_Escape"), + () => + EventUtils.synthesizeMouseAtCenter( + document.getElementById("customizableui-special-spring2"), + {} + ), + ]; + + for (const trigger of TRIGGERS) { + await doTest(async browser => { + await openPopup("test"); + await UrlbarTestUtils.promisePopupClose(window, () => { + trigger(); + }); + Assert.equal( + gURLBar.value, + "test", + "The inputted text remains even if closing the results" + ); + // The tested trigger should not record abandonment event. + assertAbandonmentTelemetry([]); + + // Endgagement. + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "", + groups: "", + }, + ]); + }); + } +}); + +add_task(async function enter_to_reload_current_url() { + await doTest(async browser => { + // Open a URL once. + await openPopup("https://example.com"); + await doEnter(); + + // Focus the urlbar. + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {}); + await BrowserTestUtils.waitForCondition( + () => window.document.activeElement === gURLBar.inputField + ); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Press Enter key to reload the page without selecting any suggestions. + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "url", + selected_result_subtype: "", + provider: "HeuristicFallback", + results: "url", + groups: "heuristic", + }, + { + selected_result: "input_field", + selected_result_subtype: "", + provider: undefined, + results: "action", + groups: "suggested_index", + }, + ]); + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js new file mode 100644 index 0000000000..9060835562 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_groups.js @@ -0,0 +1,259 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - groups +// - results +// - n_results + +add_setup(async function () { + await initGroupTest(); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,rs_adm_sponsored,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggest", + results: "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => + SimpleTest.promiseClipboardChange("100 cm", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }), + assert: () => + assertEngagementTelemetry([ + { + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function always_empty_if_drop_go() { + const expected = [ + { + engagement_type: "drop_go", + groups: "", + results: "", + n_results: 0, + }, + ]; + + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + // Open the results view once. + await showResultByArrowDown(); + await UrlbarTestUtils.promisePopupClose(window); + + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); + +add_task(async function always_empty_if_paste_go() { + const expected = [ + { + engagement_type: "paste_go", + groups: "", + results: "", + n_results: 0, + }, + ]; + + await doTest(async browser => { + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + // Open the results view once. + await showResultByArrowDown(); + await UrlbarTestUtils.promisePopupClose(window); + + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js new file mode 100644 index 0000000000..9de0de8953 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - interaction + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "typed" }]), + }); +}); + +add_task(async function dropped() { + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ interaction: "dropped" }]); + }); + + await doTest(async browser => { + await showResultByArrowDown(); + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ interaction: "dropped" }]); + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ interaction: "pasted" }]), + }); + + await doTest(async browser => { + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ interaction: "pasted" }]); + }); + + await doTest(async browser => { + await showResultByArrowDown(); + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ interaction: "pasted" }]); + }); +}); + +add_task(async function topsite_search() { + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1804010 + // assertEngagementTelemetry([{ interaction: "topsite_search" }]); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => doEnter(), + assert: expected => assertEngagementTelemetry([{ interaction: expected }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..1bdb4f0b61 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test engagement telemetry with persisted search terms disabled. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js", + this +); + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: "typed" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..33a01fdd22 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test engagement telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: "persisted_search_terms" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => doEnter(), + assert: expected => + assertEngagementTelemetry([ + { interaction: "typed" }, + { interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js new file mode 100644 index 0000000000..498ffd9532 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_n_chars_n_words.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => doEnter(), + assert: nChars => assertEngagementTelemetry([{ n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => doEnter(), + assert: nChars => assertEngagementTelemetry([{ n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => doEnter(), + assert: nWords => assertEngagementTelemetry([{ n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => doEnter(), + assert: nWords => assertEngagementTelemetry([{ n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js new file mode 100644 index 0000000000..d361d70229 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_sap.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => doEnter(), + assert: () => + assertEngagementTelemetry([{ sap: "urlbar_newtab" }, { sap: "urlbar" }]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js new file mode 100644 index 0000000000..62f87f0664 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_search_mode.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "search_engine" }]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "bookmarks" }]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => doEnter(), + assert: () => assertEngagementTelemetry([{ search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: async () => { + const currentTab = gBrowser.selectedTab; + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.waitForCondition( + () => gBrowser.selectedTab !== currentTab + ); + }, + assert: () => assertEngagementTelemetry([{ search_mode: "tabs" }]), + }); +}); + +add_task(async function actions() { + await doActionsTest({ + trigger: async () => { + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + doClickSubButton(".urlbarView-quickaction-row[data-key=addons]"); + await onLoad; + }, + assert: () => assertEngagementTelemetry([{ search_mode: "actions" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js new file mode 100644 index 0000000000..c19c511ccc --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_selected_result.js @@ -0,0 +1,920 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - selected_result +// - selected_result_subtype +// - provider +// - results + +// This test has many subtests and can time out in verify mode. +requestLongerTimeout(5); + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/ext/browser/head.js", + this +); + +add_setup(async function () { + await setup(); +}); + +add_task(async function selected_result_autofill_about() { + await doTest(async browser => { + await openPopup("about:about"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_about", + selected_result_subtype: "", + provider: "Autofill", + results: "autofill_about", + }, + ]); + }); +}); + +add_task(async function selected_result_autofill_adaptive() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill.adaptiveHistory.enabled", true]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await UrlbarUtils.addToInputHistory("https://example.com/test", "exa"); + await openPopup("exa"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_adaptive", + selected_result_subtype: "", + provider: "Autofill", + results: "autofill_adaptive", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_autofill_origin() { + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await openPopup("exa"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_origin", + selected_result_subtype: "", + provider: "Autofill", + results: "autofill_origin,history", + }, + ]); + }); +}); + +add_task(async function selected_result_autofill_url() { + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await openPopup("https://example.com/test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "autofill_url", + selected_result_subtype: "", + provider: "Autofill", + results: "autofill_url", + }, + ]); + }); +}); + +add_task(async function selected_result_bookmark() { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + + await openPopup("bookmark"); + await selectRowByURL("https://example.com/bookmark"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "bookmark", + selected_result_subtype: "", + provider: "Places", + results: "search_engine,action,bookmark", + }, + ]); + }); +}); + +add_task(async function selected_result_history() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + + await openPopup("example"); + await selectRowByURL("https://example.com/test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "history", + selected_result_subtype: "", + provider: "Places", + results: "search_engine,history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_keyword() { + await doTest(async browser => { + await PlacesUtils.keywords.insert({ + keyword: "keyword", + url: "https://example.com/?q=%s", + }); + + await openPopup("keyword test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "keyword", + selected_result_subtype: "", + provider: "BookmarkKeywords", + results: "keyword", + }, + ]); + + await PlacesUtils.keywords.remove("keyword"); + }); +}); + +add_task(async function selected_result_search_engine() { + await doTest(async browser => { + await openPopup("x"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_engine", + selected_result_subtype: "", + provider: "HeuristicFallback", + results: "search_engine", + }, + ]); + }); +}); + +add_task(async function selected_result_search_suggest() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_suggest", + selected_result_subtype: "", + provider: "SearchSuggestions", + results: "search_engine,search_suggest,search_suggest", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_search_history() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "search_history", + selected_result_subtype: "", + provider: "SearchSuggestions", + results: "search_engine,search_history,search_history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_url() { + await doTest(async browser => { + await openPopup("https://example.com/"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "url", + selected_result_subtype: "", + provider: "HeuristicFallback", + results: "url", + }, + ]); + }); +}); + +add_task(async function selected_result_action() { + await doTest(async browser => { + await showResultByArrowDown(); + await selectRowByProvider("quickactions"); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + doClickSubButton(".urlbarView-quickaction-row[data-key=addons]"); + await onLoad; + + assertEngagementTelemetry([ + { + selected_result: "action", + selected_result_subtype: "addons", + provider: "quickactions", + results: "action", + }, + ]); + }); +}); + +add_task(async function selected_result_tab() { + const tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("Places"); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.waitForCondition(() => gBrowser.selectedTab === tab); + + assertEngagementTelemetry([ + { + selected_result: "tab", + selected_result_subtype: "", + provider: "Places", + results: "search_engine,search_suggest,search_suggest,tab", + }, + ]); + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function selected_result_remote_tab() { + const remoteTab = await loadRemoteTab("https://example.com"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("RemoteTabs"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "remote_tab", + selected_result_subtype: "", + provider: "RemoteTabs", + results: "search_engine,remote_tab", + }, + ]); + }); + + await remoteTab.unload(); +}); + +add_task(async function selected_result_addon() { + const addon = loadOmniboxAddon({ keyword: "omni" }); + await addon.startup(); + + await doTest(async browser => { + await openPopup("omni test"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "addon", + selected_result_subtype: "", + provider: "Omnibox", + results: "addon", + }, + ]); + }); + + await addon.unload(); +}); + +add_task(async function selected_result_tab_to_search() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + await SearchTestUtils.installSearchExtension({ + name: "mozengine", + search_url: "https://mozengine/", + }); + + await doTest(async browser => { + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(["https://mozengine/"]); + } + + await openPopup("moze"); + await selectRowByProvider("TabToSearch"); + const onComplete = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Enter"); + await onComplete; + + assertEngagementTelemetry([ + { + selected_result: "tab_to_search", + selected_result_subtype: "", + provider: "TabToSearch", + results: "search_engine,tab_to_search,history", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_top_site() { + await doTest(async browser => { + await addTopSites("https://example.com/"); + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "top_site", + selected_result_subtype: "", + provider: "UrlbarProviderTopSites", + results: "top_site,action", + }, + ]); + }); +}); + +add_task(async function selected_result_calc() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.calculator", true]], + }); + + await doTest(async browser => { + await openPopup("8*8"); + await selectRowByProvider("calculator"); + await SimpleTest.promiseClipboardChange("64", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + + assertEngagementTelemetry([ + { + selected_result: "calc", + selected_result_subtype: "", + provider: "calculator", + results: "search_engine,calc", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_unit() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + await doTest(async browser => { + await openPopup("1m to cm"); + await selectRowByProvider("UnitConversion"); + await SimpleTest.promiseClipboardChange("100 cm", () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + + assertEngagementTelemetry([ + { + selected_result: "unit", + selected_result_subtype: "", + provider: "UnitConversion", + results: "search_engine,unit", + }, + ]); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_site_specific_contextual_search() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.contextualSearch.enabled", true]], + }); + + await doTest(async browser => { + const extension = await SearchTestUtils.installSearchExtension( + { + name: "Contextual", + search_url: "https://example.com/browser", + }, + { skipUnload: true } + ); + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + "https://example.com/" + ); + BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "https://example.com/" + ); + await onLoaded; + + await openPopup("search"); + await selectRowByProvider("UrlbarProviderContextualSearch"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "site_specific_contextual_search", + selected_result_subtype: "", + provider: "UrlbarProviderContextualSearch", + results: "search_engine,site_specific_contextual_search", + }, + ]); + + await extension.unload(); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_experimental_addon() { + const extension = await loadExtension({ + background: async () => { + browser.experiments.urlbar.addDynamicResultType("testDynamicType"); + browser.experiments.urlbar.addDynamicViewTemplate("testDynamicType", { + children: [ + { + name: "text", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }); + browser.urlbar.onBehaviorRequested.addListener(query => { + return "active"; + }, "testProvider"); + browser.urlbar.onResultsRequested.addListener(query => { + return [ + { + type: "dynamic", + source: "local", + payload: { + dynamicType: "testDynamicType", + }, + }, + ]; + }, "testProvider"); + browser.experiments.urlbar.onViewUpdateRequested.addListener(payload => { + return { + text: { + textContent: "This is a dynamic result.", + }, + }; + }, "testProvider"); + }, + }); + + await TestUtils.waitForCondition( + () => + UrlbarProvidersManager.getProvider("testProvider") && + UrlbarResult.getDynamicResultType("testDynamicType"), + "Waiting for provider and dynamic type to be registered" + ); + + await doTest(async browser => { + await openPopup("test"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + + assertEngagementTelemetry([ + { + selected_result: "experimental_addon", + selected_result_subtype: "", + provider: "testProvider", + results: "search_engine,experimental_addon", + }, + ]); + }); + + await extension.unload(); +}); + +add_task(async function selected_result_adm_sponsored() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rs_adm_sponsored", + selected_result_subtype: "", + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rs_adm_sponsored", + }, + ]); + }); + + cleanupQuickSuggest(); +}); + +add_task(async function selected_result_adm_nonsponsored() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + await doTest(async browser => { + await openPopup("nonsponsored"); + await selectRowByURL("https://example.com/nonsponsored"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "rs_adm_nonsponsored", + selected_result_subtype: "", + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,rs_adm_nonsponsored", + }, + ]); + }); + + cleanupQuickSuggest(); +}); + +add_task(async function selected_result_input_field() { + const expected = [ + { + selected_result: "input_field", + selected_result_subtype: "", + provider: null, + results: "", + }, + ]; + + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); + + await doTest(async browser => { + await doPasteAndGo("example.com"); + + assertEngagementTelemetry(expected); + }); +}); + +add_task(async function selected_result_weather() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.enabled", false]], + }); + + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + await MerinoTestUtils.initWeather(); + + await doTest(async browser => { + await openPopup(MerinoTestUtils.WEATHER_KEYWORD); + await selectRowByProvider("Weather"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "weather", + selected_result_subtype: "", + provider: "Weather", + results: "search_engine,weather", + }, + ]); + }); + + cleanupQuickSuggest(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_navigational() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + title: "Navigational suggestion", + url: "https://example.com/navigational-suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_top_picks", + selected_result_subtype: "", + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_top_picks", + }, + ]); + }); + + cleanupQuickSuggest(); +}); + +add_task(async function selected_result_dynamic_wikipedia() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + block_id: 1, + url: "https://example.com/dynamic-wikipedia", + title: "Dynamic Wikipedia suggestion", + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "dynamic-wikipedia", + provider: "wikipedia", + iab_category: "5 - Education", + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_wikipedia", + selected_result_subtype: "", + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_wikipedia", + }, + ]); + }); + + cleanupQuickSuggest(); +}); + +add_task(async function selected_result_search_shortcut_button() { + await doTest(async browser => { + const oneOffSearchButtons = UrlbarTestUtils.getOneOffSearchButtons(window); + await openPopup("x"); + Assert.ok(!oneOffSearchButtons.selectedButton); + + // Select oneoff button added for test in setup(). + for (;;) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + if (!oneOffSearchButtons.selectedButton) { + continue; + } + + if ( + oneOffSearchButtons.selectedButton.engine.name.includes( + "searchSuggestionEngine.xml" + ) + ) { + break; + } + } + + // Search immediately. + await doEnter({ shiftKey: true }); + + assertEngagementTelemetry([ + { + selected_result: "search_shortcut_button", + selected_result_subtype: "", + provider: null, + results: "search_engine", + }, + ]); + }); +}); + +add_task(async function selected_result_trending() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.trending.maxResultsNoSearchMode", 1], + ["browser.urlbar.weather.featureGate", false], + ], + }); + + let defaultEngine = await Services.search.getDefault(); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "mozengine", + search_url: "https://example.org/", + }, + { setAsDefault: true, skipUnload: true } + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig([ + { + webExtension: { id: "mozengine@tests.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + ]); + + await doTest(async browser => { + await openPopup(""); + await selectRowByProvider("SearchSuggestions"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "trending_search", + selected_result_subtype: "", + provider: "SearchSuggestions", + results: "trending_search", + }, + ]); + }); + + await extension.unload(); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_trending_rich() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.richSuggestions.featureGate", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.trending.maxResultsNoSearchMode", 1], + ["browser.urlbar.weather.featureGate", false], + ], + }); + + let defaultEngine = await Services.search.getDefault(); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "mozengine", + search_url: "https://example.org/", + }, + { setAsDefault: true, skipUnload: true } + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig([ + { + webExtension: { id: "mozengine@tests.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + ]); + + await doTest(async browser => { + await openPopup(""); + await selectRowByProvider("SearchSuggestions"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "trending_search_rich", + selected_result_subtype: "", + provider: "SearchSuggestions", + results: "trending_search_rich", + }, + ]); + }); + + await extension.unload(); + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function selected_result_addons() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.addons.featureGate", true], + ["browser.urlbar.suggest.searches", false], + ], + }); + + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + provider: "amo", + icon: "https://example.com/good-addon.svg", + url: "https://example.com/good-addon", + title: "Good Addon", + description: "This is a good addon", + custom_details: { + amo: { + rating: "4.8", + number_of_ratings: "1234567", + guid: "good@addon", + }, + }, + is_top_pick: true, + }, + ], + }); + + await doTest(async browser => { + await openPopup("only match the Merino suggestion"); + await selectRowByProvider("UrlbarProviderQuickSuggest"); + await doEnter(); + + assertEngagementTelemetry([ + { + selected_result: "merino_amo", + selected_result_subtype: "", + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_amo", + }, + ]); + }); + + cleanupQuickSuggest(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js new file mode 100644 index 0000000000..104e292788 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_tips.js @@ -0,0 +1,175 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for engagement telemetry for tips using Glean. + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser-tips/head.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +add_setup(async function () { + makeProfileResettable(); + + Services.fog.setMetricsFeatureConfig( + JSON.stringify({ "urlbar.engagement": false }) + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quickactions.enabled", false]], + }); + + registerCleanupFunction(async function () { + Services.fog.setMetricsFeatureConfig("{}"); + await SpecialPowers.popPrefEnv(); + }); +}); + +add_task(async function selected_result_tip() { + const testData = [ + { + type: "searchTip_onboard", + expected: "tip_onboard", + }, + { + type: "searchTip_persist", + expected: "tip_persist", + }, + { + type: "searchTip_redirect", + expected: "tip_redirect", + }, + { + type: "test", + expected: "tip_unknown", + }, + ]; + + for (const { type, expected } of testData) { + const deferred = PromiseUtils.defer(); + const provider = new UrlbarTestUtils.TestProvider({ + results: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type, + helpUrl: "https://example.com/", + titleL10n: { id: "urlbar-search-tips-confirm" }, + buttons: [ + { + url: "https://example.com/", + l10n: { id: "urlbar-search-tips-confirm" }, + }, + ], + } + ), + ], + priority: 1, + onEngagement: () => { + deferred.resolve(); + }, + }); + UrlbarProvidersManager.registerProvider(provider); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByType(type); + EventUtils.synthesizeKey("VK_RETURN"); + await deferred.promise; + + assertEngagementTelemetry([ + { + selected_result: expected, + results: expected, + }, + ]); + }); + + UrlbarProvidersManager.unregisterProvider(provider); + } +}); + +add_task(async function selected_result_intervention_clear() { + await doInterventionTest( + SEARCH_STRINGS.CLEAR, + "intervention_clear", + "chrome://browser/content/sanitize.xhtml", + [ + { + selected_result: "intervention_clear", + results: "search_engine,intervention_clear", + }, + ] + ); +}); + +add_task(async function selected_result_intervention_refresh() { + await doInterventionTest( + SEARCH_STRINGS.REFRESH, + "intervention_refresh", + "chrome://global/content/resetProfile.xhtml", + [ + { + selected_result: "intervention_refresh", + results: "search_engine,intervention_refresh", + }, + ] + ); +}); + +add_task(async function selected_result_intervention_update() { + // Updates are disabled for MSIX packages, this test is irrelevant for them. + if ( + AppConstants.platform === "win" && + Services.sysinfo.getProperty("hasWinPackageId") + ) { + return; + } + await UpdateUtils.setAppUpdateAutoEnabled(false); + await initUpdate({ queryString: "&noUpdates=1" }); + UrlbarProviderInterventions.checkForBrowserUpdate(true); + await processUpdateSteps([ + { + panelId: "checkingForUpdates", + checkActiveUpdate: null, + continueFile: CONTINUE_CHECK, + }, + { + panelId: "noUpdatesFound", + checkActiveUpdate: null, + continueFile: null, + }, + ]); + + await doInterventionTest( + SEARCH_STRINGS.UPDATE, + "intervention_update_refresh", + "chrome://global/content/resetProfile.xhtml", + [ + { + selected_result: "intervention_update", + results: "search_engine,intervention_update", + }, + ] + ); +}); + +async function doInterventionTest(keyword, type, dialog, expectedTelemetry) { + await doTest(async browser => { + await openPopup(keyword); + await selectRowByType(type); + const onDialog = BrowserTestUtils.promiseAlertDialog("cancel", dialog, { + isSubDialog: true, + }); + EventUtils.synthesizeKey("VK_RETURN"); + await onDialog; + + assertEngagementTelemetry(expectedTelemetry); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js new file mode 100644 index 0000000000..b2b2233b52 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_engagement_type.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of engagement telemetry. +// - engagement_type + +add_setup(async function () { + await setup(); +}); + +add_task(async function engagement_type_click() { + await doTest(async browser => { + await openPopup("x"); + await doClick(); + + assertEngagementTelemetry([{ engagement_type: "click" }]); + }); +}); + +add_task(async function engagement_type_enter() { + await doTest(async browser => { + await openPopup("x"); + await doEnter(); + + assertEngagementTelemetry([{ engagement_type: "enter" }]); + }); +}); + +add_task(async function engagement_type_go_button() { + await doTest(async browser => { + await openPopup("x"); + EventUtils.synthesizeMouseAtCenter(gURLBar.goButton, {}); + + assertEngagementTelemetry([{ engagement_type: "go_button" }]); + }); +}); + +add_task(async function engagement_type_drop_go() { + await doTest(async browser => { + await doDropAndGo("example.com"); + + assertEngagementTelemetry([{ engagement_type: "drop_go" }]); + }); +}); + +add_task(async function engagement_type_paste_go() { + await doTest(async browser => { + await doPasteAndGo("www.example.com"); + + assertEngagementTelemetry([{ engagement_type: "paste_go" }]); + }); +}); + +add_task(async function engagement_type_dismiss() { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + // eslint-disable-next-line mozilla/valid-lazy + config: lazy.QuickSuggestTestUtils.BEST_MATCH_CONFIG, + }); + + for (const isBestMatchTest of [true, false]) { + const prefs = isBestMatchTest + ? [ + ["browser.urlbar.bestMatch.enabled", true], + ["browser.urlbar.bestMatch.blockingEnabled", true], + ["browser.urlbar.quicksuggest.blockingEnabled", false], + ] + : [ + ["browser.urlbar.bestMatch.enabled", false], + ["browser.urlbar.bestMatch.blockingEnabled", false], + ["browser.urlbar.quicksuggest.blockingEnabled", true], + ]; + await SpecialPowers.pushPrefEnv({ set: prefs }); + + await doTest(async browser => { + await openPopup("sponsored"); + + const originalResultCount = UrlbarTestUtils.getResultCount(window); + await selectRowByURL("https://example.com/sponsored"); + if (UrlbarPrefs.get("resultMenu")) { + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D"); + } else { + doClickSubButton(".urlbarView-button-block"); + } + await BrowserTestUtils.waitForCondition( + () => originalResultCount != UrlbarTestUtils.getResultCount(window) + ); + + assertEngagementTelemetry([{ engagement_type: "dismiss" }]); + + // The view should stay open after dismissing the result. Now pick the + // heuristic result. Another "click" engagement event should be recorded. + Assert.ok( + gURLBar.view.isOpen, + "View should remain open after dismissing result" + ); + await doClick(); + assertEngagementTelemetry([ + { engagement_type: "dismiss" }, + { engagement_type: "click", interaction: "typed" }, + ]); + }); + + await doTest(async browser => { + await openPopup("sponsored"); + + const originalResultCount = UrlbarTestUtils.getResultCount(window); + await selectRowByURL("https://example.com/sponsored"); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + await BrowserTestUtils.waitForCondition( + () => originalResultCount != UrlbarTestUtils.getResultCount(window) + ); + + assertEngagementTelemetry([{ engagement_type: "dismiss" }]); + }); + + await SpecialPowers.popPrefEnv(); + } + + cleanupQuickSuggest(); +}); + +add_task(async function engagement_type_help() { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + + for (const isBestMatchTest of [true, false]) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", isBestMatchTest]], + }); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + const onTabOpened = BrowserTestUtils.waitForNewTab(gBrowser); + if (UrlbarPrefs.get("resultMenu")) { + UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "L"); + } else { + doClickSubButton(".urlbarView-button-help"); + } + const tab = await onTabOpened; + BrowserTestUtils.removeTab(tab); + + assertEngagementTelemetry([{ engagement_type: "help" }]); + }); + + await SpecialPowers.popPrefEnv(); + } + + cleanupQuickSuggest(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js new file mode 100644 index 0000000000..7529f29455 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_exposure.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SPONSORED_QUERY = "sponsored"; +const NONSPONSORED_QUERY = "nonsponsored"; + +// test for exposure events +add_setup(async function () { + await initExposureTest(); +}); + +add_task(async function exposureSponsoredOnEngagement() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", "rs_adm_sponsored"], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + trigger: () => doClick(), + assert: () => assertExposureTelemetry([{ results: "rs_adm_sponsored" }]), + }); +}); + +add_task(async function exposureSponsoredOnAbandonment() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", "rs_adm_sponsored"], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + trigger: () => doBlur(), + assert: () => assertExposureTelemetry([{ results: "rs_adm_sponsored" }]), + }); +}); + +add_task(async function exposureFilter() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", "rs_adm_sponsored"], + ["browser.urlbar.showExposureResults", false], + ], + query: SPONSORED_QUERY, + select: async () => { + // assert that the urlbar has no results + Assert.equal(await getResultByType("rs_adm_sponsored"), null); + }, + trigger: () => doBlur(), + assert: () => assertExposureTelemetry([{ results: "rs_adm_sponsored" }]), + }); +}); + +add_task(async function innerQueryExposure() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", "rs_adm_sponsored"], + ["browser.urlbar.showExposureResults", true], + ], + query: NONSPONSORED_QUERY, + select: () => {}, + trigger: async () => { + // delete the old query + gURLBar.select(); + EventUtils.synthesizeKey("KEY_Backspace"); + await openPopup(SPONSORED_QUERY); + await defaultSelect(SPONSORED_QUERY); + await doClick(); + }, + assert: () => assertExposureTelemetry([{ results: "rs_adm_sponsored" }]), + }); +}); + +add_task(async function innerQueryInvertedExposure() { + await doExposureTest({ + prefs: [ + ["browser.urlbar.exposureResults", "rs_adm_sponsored"], + ["browser.urlbar.showExposureResults", true], + ], + query: SPONSORED_QUERY, + select: () => {}, + trigger: async () => { + // delete the old query + gURLBar.select(); + EventUtils.synthesizeKey("KEY_Backspace"); + await openPopup(NONSPONSORED_QUERY); + await defaultSelect(SPONSORED_QUERY); + await doClick(); + }, + assert: () => assertExposureTelemetry([{ results: "rs_adm_sponsored" }]), + }); +}); + +add_task(async function multipleProviders() { + await doExposureTest({ + prefs: [ + [ + "browser.urlbar.exposureResults", + "rs_adm_sponsored,rs_adm_nonsponsored", + ], + ["browser.urlbar.showExposureResults", true], + ], + query: NONSPONSORED_QUERY, + trigger: () => doClick(), + assert: () => assertExposureTelemetry([{ results: "rs_adm_nonsponsored" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js new file mode 100644 index 0000000000..199460e312 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_groups.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - groups +// - results +// - n_results + +add_setup(async function () { + await initGroupTest(); + // Increase the pausing time to ensure to ready for all suggestions. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 500, + ], + ], + }); +}); + +add_task(async function heuristics() { + await doHeuristicsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", groups: "heuristic", results: "search_engine" }, + ]), + }); +}); + +add_task(async function adaptive_history() { + await doAdaptiveHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,adaptive_history", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function search_history() { + await doSearchHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_history,search_history", + results: "search_engine,search_history,search_history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function search_suggest() { + await doSearchSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_suggest,search_suggest", + results: "search_engine,search_suggest,search_suggest", + n_results: 3, + }, + ]), + }); + + await doTailSearchSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,search_suggest", + results: "search_engine,search_suggest", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function top_pick() { + await doTopPickTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,top_pick,search_suggest,search_suggest", + results: + "search_engine,rs_adm_sponsored,search_suggest,search_suggest", + n_results: 4, + }, + ]), + }); +}); + +add_task(async function top_site() { + await doTopSiteTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "top_site,suggested_index", + results: "top_site,action", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function remote_tab() { + await doRemoteTabTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,remote_tab", + results: "search_engine,remote_tab", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function addon() { + await doAddonTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "addon", + results: "addon", + n_results: 1, + }, + ]), + }); +}); + +add_task(async function general() { + await doGeneralBookmarkTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,suggested_index,general", + results: "search_engine,action,bookmark", + n_results: 3, + }, + ]), + }); + + await doGeneralHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,general", + results: "search_engine,history", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function suggest() { + await doSuggestTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + groups: "heuristic,suggest", + results: "search_engine,rs_adm_nonsponsored", + n_results: 2, + }, + ]), + }); +}); + +add_task(async function about_page() { + await doAboutPageTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,about_page,about_page", + results: "search_engine,history,history", + n_results: 3, + }, + ]), + }); +}); + +add_task(async function suggested_index() { + await doSuggestedIndexTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { + reason: "pause", + groups: "heuristic,suggested_index", + results: "search_engine,unit", + n_results: 2, + }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js new file mode 100644 index 0000000000..f783bf766c --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - interaction + +add_setup(async function () { + await initInteractionTest(); +}); + +add_task(async function topsites() { + await doTopsitesTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "topsites" }]), + }); +}); + +add_task(async function typed() { + await doTypedTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "typed" }]), + }); + + await doTypedWithResultsPopupTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "typed" }]), + }); +}); + +add_task(async function pasted() { + await doPastedTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "pasted" }]), + }); + + await doPastedWithResultsPopupTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", interaction: "pasted" }]), + }); +}); + +add_task(async function topsite_search() { + // TODO: https://bugzilla.mozilla.org/show_bug.cgi?id=1804010 + // assertImpressionTelemetry([{ interaction: "topsite_search" }]); +}); + +add_task(async function returned_restarted_refined() { + await doReturnedRestartedRefinedTest({ + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js new file mode 100644 index 0000000000..af7134b3a0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_disabled.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test impression telemetry with persisted search terms disabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.showSearchTerms.featureGate", false]], + }); +}); + +add_task(async function persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: "typed" }, + ]), + }); +}); + +add_task(async function persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: false, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); + +add_task( + async function persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: false, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js new file mode 100644 index 0000000000..a29ff98b78 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_interaction_persisted_search_terms_enabled.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test impression telemetry with persisted search terms enabled. + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initInteractionTest(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.showSearchTerms.enabled", true], + ["browser.search.widget.inNavBar", false], + ], + }); +}); + +add_task(async function interaction_persisted_search_terms() { + await doPersistedSearchTermsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: "persisted_search_terms" }, + ]), + }); +}); + +add_task(async function interaction_persisted_search_terms_restarted_refined() { + await doPersistedSearchTermsRestartedRefinedTest({ + enabled: true, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); +}); + +add_task( + async function interaction_persisted_search_terms_restarted_refined_via_abandonment() { + await doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled: true, + trigger: () => waitForPauseImpression(), + assert: expected => + assertImpressionTelemetry([ + { reason: "pause" }, + { reason: "pause" }, + { reason: "pause", interaction: expected }, + ]), + }); + } +); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js new file mode 100644 index 0000000000..528cc318e0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_n_chars_n_words.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - n_chars +// - n_words + +add_setup(async function () { + await initNCharsAndNWordsTest(); +}); + +add_task(async function n_chars() { + await doNCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nChars => + assertImpressionTelemetry([{ reason: "pause", n_chars: nChars }]), + }); + + await doNCharsWithOverMaxTextLengthCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nChars => + assertImpressionTelemetry([{ reason: "pause", n_chars: nChars }]), + }); +}); + +add_task(async function n_words() { + await doNWordsTest({ + trigger: () => waitForPauseImpression(), + assert: nWords => + assertImpressionTelemetry([{ reason: "pause", n_words: nWords }]), + }); + + await doNWordsWithOverMaxTextLengthCharsTest({ + trigger: () => waitForPauseImpression(), + assert: nWords => + assertImpressionTelemetry([{ reason: "pause", n_words: nWords }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js new file mode 100644 index 0000000000..344e238e24 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_preferences.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the impression telemetry behavior with its preferences. + +add_setup(async function () { + await setup(); +}); + +add_task(async function pauseImpressionIntervalMs() { + const additionalInterval = 1000; + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + originalInterval + additionalInterval, + ], + ], + }); + + await doTest(async browser => { + await openPopup("https://example.com"); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, originalInterval)); + await Services.fog.testFlushAllChildren(); + assertImpressionTelemetry([]); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, additionalInterval)); + await Services.fog.testFlushAllChildren(); + assertImpressionTelemetry([{ sap: "urlbar_newtab" }]); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js new file mode 100644 index 0000000000..482b906024 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_sap.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - sap + +add_setup(async function () { + await initSapTest(); +}); + +add_task(async function urlbar() { + await doUrlbarTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", sap: "urlbar_newtab" }, + { reason: "pause", sap: "urlbar" }, + ]), + }); +}); + +add_task(async function handoff() { + await doHandoffTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", sap: "handoff" }]), + }); +}); + +add_task(async function urlbar_addonpage() { + await doUrlbarAddonpageTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", sap: "urlbar_addonpage" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js new file mode 100644 index 0000000000..727afa3cef --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_search_mode.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the following data of impression telemetry. +// - search_mode + +add_setup(async function () { + await initSearchModeTest(); + // Increase the pausing time to ensure entering search mode. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 1000, + ], + ], + }); +}); + +add_task(async function not_search_mode() { + await doNotSearchModeTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "" }]), + }); +}); + +add_task(async function search_engine() { + await doSearchEngineTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", search_mode: "search_engine" }, + ]), + }); +}); + +add_task(async function bookmarks() { + await doBookmarksTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([ + { reason: "pause", search_mode: "bookmarks" }, + ]), + }); +}); + +add_task(async function history() { + await doHistoryTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "history" }]), + }); +}); + +add_task(async function tabs() { + await doTabTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "tabs" }]), + }); +}); + +add_task(async function actions() { + await doActionsTest({ + trigger: () => waitForPauseImpression(), + assert: () => + assertImpressionTelemetry([{ reason: "pause", search_mode: "actions" }]), + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js new file mode 100644 index 0000000000..31f64996f3 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_impression_timing.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the taking timing for the impression telemetry. + +add_setup(async function () { + await setup(); +}); + +add_task(async function cancelImpressionTimerByEngagementEvent() { + const additionalInterval = 1000; + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + originalInterval + additionalInterval, + ], + ], + }); + + for (const trigger of [doEnter, doBlur]) { + await doTest(async browser => { + await openPopup("https://example.com"); + await trigger(); + + // Check whether the impression timer was canceled. + await new Promise(r => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(r, originalInterval + additionalInterval) + ); + assertImpressionTelemetry([]); + }); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function cancelInpressionTimerByType() { + const originalInterval = UrlbarPrefs.get( + "searchEngagementTelemetry.pauseImpressionIntervalMs" + ); + + await doTest(async browser => { + await openPopup("x"); + await new Promise(r => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(r, originalInterval / 10) + ); + assertImpressionTelemetry([]); + + EventUtils.synthesizeKey(" "); + EventUtils.synthesizeKey("z"); + await UrlbarTestUtils.promiseSearchComplete(window); + assertImpressionTelemetry([]); + await waitForPauseImpression(); + + assertImpressionTelemetry([{ n_chars: 3 }]); + }); +}); + +add_task(async function oneImpressionInOneSession() { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + + // Sanity check. + assertImpressionTelemetry([{ n_chars: 1 }]); + + // Add a keyword to start new query. + EventUtils.synthesizeKey(" "); + EventUtils.synthesizeKey("z"); + await UrlbarTestUtils.promiseSearchComplete(window); + await waitForPauseImpression(); + + // No more taking impression telemetry. + assertImpressionTelemetry([{ n_chars: 1 }]); + + // Finish the current session. + await doEnter(); + + // Should take pause impression since new session started. + await openPopup("x z y"); + await waitForPauseImpression(); + assertImpressionTelemetry([{ n_chars: 1 }, { n_chars: 5 }]); + }); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js new file mode 100644 index 0000000000..6760385841 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_record_preferences.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for preference telemetry. + +add_setup(async function () { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + + // Create a new window in order to initialize TelemetryEvent of + // UrlbarController. + const win = await BrowserTestUtils.openNewBrowserWindow(); + registerCleanupFunction(async function () { + await BrowserTestUtils.closeWindow(win); + }); +}); + +add_task(async function prefMaxRichResults() { + Assert.equal( + Glean.urlbar.prefMaxResults.testGetValue(), + UrlbarPrefs.get("maxRichResults"), + "Record prefMaxResults when UrlbarController is initialized" + ); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxRichResults", 0]], + }); + Assert.equal( + Glean.urlbar.prefMaxResults.testGetValue(), + UrlbarPrefs.get("maxRichResults"), + "Record prefMaxResults when the maxRichResults pref is updated" + ); +}); + +add_task(async function prefSuggestTopsites() { + Assert.equal( + Glean.urlbar.prefSuggestTopsites.testGetValue(), + UrlbarPrefs.get("suggest.topsites"), + "Record prefSuggestTopsites when UrlbarController is initialized" + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", !UrlbarPrefs.get("suggest.topsites")], + ], + }); + Assert.equal( + Glean.urlbar.prefSuggestTopsites.testGetValue(), + UrlbarPrefs.get("suggest.topsites"), + "Record prefSuggestTopsites when the suggest.topsites pref is updated" + ); +}); diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js new file mode 100644 index 0000000000..4ce2c6f869 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doExposureTest({ + prefs, + query, + trigger, + assert, + select = defaultSelect, +}) { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + await SpecialPowers.pushPrefEnv({ + set: prefs, + }); + + await doTest(async () => { + await openPopup(query); + await select(query); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); + cleanupQuickSuggest(); +} + +async function defaultSelect(query) { + await selectRowByURL(`https://example.com/${query}`); +} + +async function getResultByType(provider) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + const telemetryType = UrlbarUtils.searchEngagementTelemetryType( + detail.result + ); + if (telemetryType === provider) { + return detail.result; + } + } + return null; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js new file mode 100644 index 0000000000..b34221d749 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doHeuristicsTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doAdaptiveHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits(["https://example.com/test"]); + await UrlbarUtils.addToInputHistory("https://example.com/test", "examp"); + + await openPopup("exa"); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSearchHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await UrlbarTestUtils.formHistory.add(["foofoo", "foobar"]); + + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSearchSuggestTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.maxHistoricalSearchSuggestions", 2], + ], + }); + + await doTest(async browser => { + await openPopup("foo"); + await selectRowByURL("http://mochi.test:8888/?terms=foofoo"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doTailSearchSuggestTest({ trigger, assert }) { + const cleanup = await _useTailSuggestionsEngine(); + + await doTest(async browser => { + await openPopup("hello"); + await selectRowByProvider("SearchSuggestions"); + + await trigger(); + await assert(); + }); + + await cleanup(); +} + +async function doTopPickTest({ trigger, assert }) { + const cleanupQuickSuggest = await ensureQuickSuggestInit({ + // eslint-disable-next-line mozilla/valid-lazy + config: lazy.QuickSuggestTestUtils.BEST_MATCH_CONFIG, + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", true]], + }); + + await doTest(async browser => { + await openPopup("sponsored"); + await selectRowByURL("https://example.com/sponsored"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); + cleanupQuickSuggest(); +} + +async function doTopSiteTest({ trigger, assert }) { + await doTest(async browser => { + await addTopSites("https://example.com/"); + + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + + await trigger(); + await assert(); + }); +} + +async function doRemoteTabTest({ trigger, assert }) { + const remoteTab = await loadRemoteTab("https://example.com"); + + await doTest(async browser => { + await openPopup("example"); + await selectRowByProvider("RemoteTabs"); + + await trigger(); + await assert(); + }); + + await remoteTab.unload(); +} + +async function doAddonTest({ trigger, assert }) { + const addon = loadOmniboxAddon({ keyword: "omni" }); + await addon.startup(); + + await doTest(async browser => { + await openPopup("omni test"); + + await trigger(); + await assert(); + }); + + await addon.unload(); +} + +async function doGeneralBookmarkTest({ trigger, assert }) { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + + await openPopup("bookmark"); + await selectRowByURL("https://example.com/bookmark"); + + await trigger(); + await assert(); + }); +} + +async function doGeneralHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + + await openPopup("example"); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSuggestTest({ trigger, assert }) { + const cleanupQuickSuggest = await ensureQuickSuggestInit(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", false]], + }); + + await doTest(async browser => { + await openPopup("nonsponsored"); + await selectRowByURL("https://example.com/nonsponsored"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); + cleanupQuickSuggest(); +} + +async function doAboutPageTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxRichResults", 3]], + }); + + await doTest(async browser => { + await openPopup("about:"); + await selectRowByURL("about:robots"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doSuggestedIndexTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.unitConversion.enabled", true]], + }); + + await doTest(async browser => { + await openPopup("1m to cm"); + await selectRowByProvider("UnitConversion"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +/** + * Creates a search engine that returns tail suggestions and sets it as the + * default engine. + * + * @returns {Function} + * A cleanup function that will revert the default search engine and stop http + * server. + */ +async function _useTailSuggestionsEngine() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.suggest.enabled", true], + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.richSuggestions.tail", true], + ], + }); + + const engineName = "TailSuggestions"; + const httpServer = new HttpServer(); + httpServer.start(-1); + httpServer.registerPathHandler("/suggest", (req, resp) => { + const params = new URLSearchParams(req.queryString); + const searchStr = params.get("q"); + const suggestions = [ + searchStr, + [searchStr + "-tail"], + [], + { + "google:suggestdetail": [{ t: "-tail", mp: "… " }], + }, + ]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(suggestions)); + }); + + await SearchTestUtils.installSearchExtension({ + name: engineName, + search_url: `http://localhost:${httpServer.identity.primaryPort}/search`, + suggest_url: `http://localhost:${httpServer.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + search_form: `http://localhost:${httpServer.identity.primaryPort}/search?q={searchTerms}`, + }); + + const tailEngine = Services.search.getEngineByName(engineName); + const originalEngine = await Services.search.getDefault(); + Services.search.setDefault( + tailEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + return async () => { + Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + httpServer.stop(() => {}); + await SpecialPowers.popPrefEnv(); + }; +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js new file mode 100644 index 0000000000..6bc0fd3b0e --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js @@ -0,0 +1,238 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doTopsitesTest({ trigger, assert }) { + await doTest(async browser => { + await addTopSites("https://example.com/"); + + await showResultByArrowDown(); + await selectRowByURL("https://example.com/"); + + await trigger(); + await assert(); + }); +} + +async function doTypedTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doTypedWithResultsPopupTest({ trigger, assert }) { + await doTest(async browser => { + await showResultByArrowDown(); + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(); + }); +} + +async function doPastedTest({ trigger, assert }) { + await doTest(async browser => { + await doPaste("www.example.com"); + + await trigger(); + await assert(); + }); +} + +async function doPastedWithResultsPopupTest({ trigger, assert }) { + await doTest(async browser => { + await showResultByArrowDown(); + await doPaste("x"); + + await trigger(); + await assert(); + }); +} + +async function doReturnedRestartedRefinedTest({ trigger, assert }) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after blur. + secondInput: null, + expected: "returned", + }, + { + firstInput: "x", + secondInput: "x", + expected: "returned", + }, + { + firstInput: "x", + secondInput: "y", + expected: "restarted", + }, + { + firstInput: "x", + secondInput: "x y", + expected: "refined", + }, + { + firstInput: "x y", + secondInput: "x", + expected: "refined", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup(firstInput); + await waitForPauseImpression(); + await doBlur(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + document.getElementById("Browser:OpenLocation").doCommand(); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} + +async function doPersistedSearchTermsTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + await doEnter(); + + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doPersistedSearchTermsRestartedRefinedTest({ + enabled, + trigger, + assert, +}) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after engagement. + secondInput: null, + expected: enabled ? "persisted_search_terms" : "topsites", + }, + { + firstInput: "x", + secondInput: "x", + expected: enabled ? "persisted_search_terms" : "typed", + }, + { + firstInput: "x", + secondInput: "y", + expected: enabled ? "persisted_search_terms_restarted" : "typed", + }, + { + firstInput: "x", + secondInput: "x y", + expected: enabled ? "persisted_search_terms_refined" : "typed", + }, + { + firstInput: "x y", + secondInput: "x", + expected: enabled ? "persisted_search_terms_refined" : "typed", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup(firstInput); + await waitForPauseImpression(); + await doEnter(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} + +async function doPersistedSearchTermsRestartedRefinedViaAbandonmentTest({ + enabled, + trigger, + assert, +}) { + const testData = [ + { + firstInput: "x", + // Just move the focus to the URL bar after blur. + secondInput: null, + expected: enabled ? "persisted_search_terms" : "returned", + }, + { + firstInput: "x", + secondInput: "x", + expected: enabled ? "persisted_search_terms" : "returned", + }, + { + firstInput: "x", + secondInput: "y", + expected: enabled ? "persisted_search_terms_restarted" : "restarted", + }, + { + firstInput: "x", + secondInput: "x y", + expected: enabled ? "persisted_search_terms_refined" : "refined", + }, + { + firstInput: "x y", + secondInput: "x", + expected: enabled ? "persisted_search_terms_refined" : "refined", + }, + ]; + + for (const { firstInput, secondInput, expected } of testData) { + await doTest(async browser => { + await openPopup("any search"); + await waitForPauseImpression(); + await doEnter(); + + await openPopup(firstInput); + await waitForPauseImpression(); + await doBlur(); + + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("l", { accelKey: true }); + }); + if (secondInput) { + for (let i = 0; i < secondInput.length; i++) { + EventUtils.synthesizeKey(secondInput.charAt(i)); + } + } + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(expected); + }); + } +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js new file mode 100644 index 0000000000..6d4c61c7f0 --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doNCharsTest({ trigger, assert }) { + for (const input of ["x", "xx", "xx x", "xx x "]) { + await doTest(async browser => { + await openPopup(input); + + await trigger(); + await assert(input.length); + }); + } +} + +async function doNCharsWithOverMaxTextLengthCharsTest({ trigger, assert }) { + await doTest(async browser => { + let input = ""; + for (let i = 0; i < UrlbarUtils.MAX_TEXT_LENGTH * 2; i++) { + input += "x"; + } + await openPopup(input); + + await trigger(); + await assert(UrlbarUtils.MAX_TEXT_LENGTH * 2); + }); +} + +async function doNWordsTest({ trigger, assert }) { + for (const input of ["x", "xx", "xx x", "xx x "]) { + await doTest(async browser => { + await openPopup(input); + + await trigger(); + const splits = input.trim().split(" "); + await assert(splits.length); + }); + } +} + +async function doNWordsWithOverMaxTextLengthCharsTest({ trigger, assert }) { + await doTest(async browser => { + const word = "1234 "; + let input = ""; + while (input.length < UrlbarUtils.MAX_TEXT_LENGTH * 2) { + input += word; + } + await openPopup(input); + + await trigger(); + await assert(UrlbarUtils.MAX_TEXT_LENGTH / word.length); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js new file mode 100644 index 0000000000..be4e852b1c --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doUrlbarNewTabTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doUrlbarTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await waitForPauseImpression(); + await doEnter(); + await openPopup("y"); + + await trigger(); + await assert(); + }); +} + +async function doHandoffTest({ trigger, assert }) { + await doTest(async browser => { + BrowserTestUtils.loadURIString(browser, "about:newtab"); + await BrowserTestUtils.browserStopped(browser, "about:newtab"); + await SpecialPowers.spawn(browser, [], function () { + const searchInput = content.document.querySelector(".fake-editable"); + searchInput.click(); + }); + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + await trigger(); + await assert(); + }); +} + +async function doUrlbarAddonpageTest({ trigger, assert }) { + const extensionData = { + files: { + "page.html": "<!DOCTYPE html>hello", + }, + }; + const extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + const extensionURL = `moz-extension://${extension.uuid}/page.html`; + + await doTest(async browser => { + const onLoad = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.loadURIString(browser, extensionURL); + await onLoad; + await openPopup("x"); + + await trigger(); + await assert(); + }); + + await extension.unload(); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js new file mode 100644 index 0000000000..5c877da05f --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from head.js */ + +async function doNotSearchModeTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + + await trigger(); + await assert(); + }); +} + +async function doSearchEngineTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("x"); + await UrlbarTestUtils.enterSearchMode(window); + + await trigger(); + await assert(); + }); +} + +async function doBookmarksTest({ trigger, assert }) { + await doTest(async browser => { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "https://example.com/bookmark", + title: "bookmark", + }); + await openPopup("bookmark"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + }); + await selectRowByURL("https://example.com/bookmark"); + + await trigger(); + await assert(); + }); +} + +async function doHistoryTest({ trigger, assert }) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", false]], + }); + + await doTest(async browser => { + await PlacesTestUtils.addVisits("https://example.com/test"); + await openPopup("example"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + await selectRowByURL("https://example.com/test"); + + await trigger(); + await assert(); + }); + + await SpecialPowers.popPrefEnv(); +} + +async function doTabTest({ trigger, assert }) { + const tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + + await doTest(async browser => { + await openPopup("example"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.TABS, + }); + await selectRowByProvider("Places"); + + await trigger(); + await assert(); + }); + + BrowserTestUtils.removeTab(tab); +} + +async function doActionsTest({ trigger, assert }) { + await doTest(async browser => { + await openPopup("add"); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + }); + await selectRowByProvider("quickactions"); + + await trigger(); + await assert(); + }); +} diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/head.js b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js new file mode 100644 index 0000000000..fa623deb5c --- /dev/null +++ b/browser/components/urlbar/tests/engagementTelemetry/browser/head.js @@ -0,0 +1,458 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", +}); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineESModuleGetters(lazy, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +async function addTopSites(url) { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + await updateTopSites(sites => { + return sites && sites[0] && sites[0].url == url; + }); +} + +function assertAbandonmentTelemetry(expectedExtraList) { + _assertGleanTelemetry("abandonment", expectedExtraList); +} + +function assertEngagementTelemetry(expectedExtraList) { + _assertGleanTelemetry("engagement", expectedExtraList); +} + +function assertImpressionTelemetry(expectedExtraList) { + _assertGleanTelemetry("impression", expectedExtraList); +} + +function assertExposureTelemetry(expectedExtraList) { + _assertGleanTelemetry("exposure", expectedExtraList); +} + +function _assertGleanTelemetry(telemetryName, expectedExtraList) { + const telemetries = Glean.urlbar[telemetryName].testGetValue() ?? []; + Assert.equal( + telemetries.length, + expectedExtraList.length, + "Telemetry event length matches expected event length." + ); + + for (let i = 0; i < telemetries.length; i++) { + const telemetry = telemetries[i]; + Assert.equal(telemetry.category, "urlbar"); + Assert.equal(telemetry.name, telemetryName); + + const expectedExtra = expectedExtraList[i]; + for (const key of Object.keys(expectedExtra)) { + Assert.equal( + telemetry.extra[key], + expectedExtra[key], + `${key} is correct` + ); + } + } +} + +async function ensureQuickSuggestInit({ + merinoSuggestions = undefined, + config = undefined, +} = {}) { + return lazy.QuickSuggestTestUtils.ensureQuickSuggestInit({ + config, + merinoSuggestions, + remoteSettingsResults: [ + { + type: "data", + attachment: [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: `https://example.com/nonsponsored`, + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "5 - Education", + }, + ], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ], + }); +} + +async function doBlur() { + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); +} + +async function doClick() { + const selected = UrlbarTestUtils.getSelectedRow(window); + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeMouseAtCenter(selected, {}); + await onLoad; +} + +async function doClickSubButton(selector) { + const selected = UrlbarTestUtils.getSelectedElement(window); + const button = selected.closest(".urlbarView-row").querySelector(selector); + EventUtils.synthesizeMouseAtCenter(button, {}); +} + +async function doDropAndGo(data) { + const onLoad = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeDrop( + document.getElementById("back-button"), + gURLBar.inputField, + [[{ type: "text/plain", data }]], + "copy", + window + ); + await onLoad; +} + +async function doEnter(modifier = {}) { + const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeKey("KEY_Enter", modifier); + await onLoad; +} + +async function doPaste(data) { + await SimpleTest.promiseClipboardChange(data, () => { + clipboardHelper.copyString(data); + }); + + gURLBar.focus(); + gURLBar.select(); + document.commandDispatcher + .getControllerForCommand("cmd_paste") + .doCommand("cmd_paste"); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function doPasteAndGo(data) { + await SimpleTest.promiseClipboardChange(data, () => { + clipboardHelper.copyString(data); + }); + const inputBox = gURLBar.querySelector("moz-input-box"); + const contextMenu = inputBox.menupopup; + const onPopup = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { + type: "contextmenu", + button: 2, + }); + await onPopup; + const onLoad = BrowserTestUtils.browserLoaded(browser); + const menuitem = inputBox.getMenuItem("paste-and-go"); + contextMenu.activateItem(menuitem); + await onLoad; +} + +async function doTest(testFn) { + await Services.fog.testFlushAllChildren(); + Services.fog.testResetFOG(); + // Enable recording telemetry for abandonment, engagement and impression. + Services.fog.setMetricsFeatureConfig( + JSON.stringify({ + "urlbar.abandonment": true, + "urlbar.engagement": true, + "urlbar.impression": true, + }) + ); + + gURLBar.controller.engagementEvent.reset(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesTestUtils.clearHistoryVisits(); + await PlacesTestUtils.clearInputHistory(); + await UrlbarTestUtils.formHistory.clear(window); + await QuickSuggest.blockedSuggestions.clear(); + await QuickSuggest.blockedSuggestions._test_readyPromise; + await updateTopSites(() => true); + + try { + await BrowserTestUtils.withNewTab(gBrowser, testFn); + } finally { + Services.fog.setMetricsFeatureConfig("{}"); + } +} + +async function initGroupTest() { + /* import-globals-from head-groups.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-groups.js", + this + ); + await setup(); +} + +async function initInteractionTest() { + /* import-globals-from head-interaction.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-interaction.js", + this + ); + await setup(); +} + +async function initNCharsAndNWordsTest() { + /* import-globals-from head-n_chars_n_words.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-n_chars_n_words.js", + this + ); + await setup(); +} + +async function initSapTest() { + /* import-globals-from head-sap.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-sap.js", + this + ); + await setup(); +} + +async function initSearchModeTest() { + /* import-globals-from head-search_mode.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-search_mode.js", + this + ); + await setup(); +} + +async function initExposureTest() { + /* import-globals-from head-exposure.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/engagementTelemetry/browser/head-exposure.js", + this + ); + await setup(); +} + +function loadOmniboxAddon({ keyword }) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + omnibox: { + keyword, + }, + }, + background() { + /* global browser */ + browser.omnibox.setDefaultSuggestion({ + description: "doit", + }); + browser.omnibox.onInputEntered.addListener(() => { + browser.tabs.update({ url: "https://example.com/" }); + }); + browser.omnibox.onInputChanged.addListener((text, suggest) => { + suggest([]); + }); + }, + }); +} + +async function loadRemoteTab(url) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", false], + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.urlbar.autoFill", false], + ["services.sync.username", "fake"], + ["services.sync.syncedTabs.showRemoteTabs", true], + ], + }); + + const REMOTE_TAB = { + id: "test", + type: "client", + lastModified: 1492201200, + name: "test", + clientType: "desktop", + tabs: [ + { + type: "tab", + title: "tesrt", + url, + icon: UrlbarUtils.ICON.DEFAULT, + client: "test", + lastUsed: Math.floor(Date.now() / 1000), + }, + ], + }; + + const sandbox = lazy.sinon.createSandbox(); + // eslint-disable-next-line no-undef + const syncedTabs = SyncedTabs; + const originalSyncedTabsInternal = syncedTabs._internal; + syncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { + return Promise.resolve([]); + }, + syncTabs() { + return Promise.resolve(); + }, + }; + const weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + const oldWeaveServiceReady = weaveXPCService.ready; + weaveXPCService.ready = true; + sandbox + .stub(syncedTabs._internal, "getTabClients") + .callsFake(() => Promise.resolve(Cu.cloneInto([REMOTE_TAB], {}))); + + return { + async unload() { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + syncedTabs._internal = originalSyncedTabsInternal; + // Reset internal cache in UrlbarProviderRemoteTabs. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + await SpecialPowers.popPrefEnv(); + }, + }; +} + +async function openPopup(input) { + await UrlbarTestUtils.promisePopupOpen(window, async () => { + await UrlbarTestUtils.inputIntoURLBar(window, input); + }); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function selectRowByURL(url) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.url === url) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + return; + } + } +} + +async function selectRowByProvider(provider) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.providerName === provider) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + break; + } + } +} + +async function selectRowByType(type) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const detail = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (detail.result.payload.type === type) { + UrlbarTestUtils.setSelectedRowIndex(window, i); + return; + } + } +} + +async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.searchEngagementTelemetry.enabled", true], + ["browser.urlbar.quickactions.enabled", true], + ["browser.urlbar.quickactions.minimumSearchString", 0], + ["browser.urlbar.suggest.quickactions", true], + ["browser.urlbar.shortcuts.quickactions", true], + [ + "browser.urlbar.searchEngagementTelemetry.pauseImpressionIntervalMs", + 100, + ], + ], + }); + + const engine = await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.xml", + }); + const originalDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 0); + + registerCleanupFunction(async function () { + await SpecialPowers.popPrefEnv(); + await Services.search.setDefault( + originalDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +} + +async function setupNimbus(variables) { + return lazy.UrlbarTestUtils.initNimbusFeature(variables); +} + +async function showResultByArrowDown() { + gURLBar.value = ""; + gURLBar.select(); + await UrlbarTestUtils.promisePopupOpen(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + }); + await UrlbarTestUtils.promiseSearchComplete(window); +} + +async function waitForPauseImpression() { + await new Promise(r => + setTimeout( + r, + UrlbarPrefs.get("searchEngagementTelemetry.pauseImpressionIntervalMs") + ) + ); + await Services.fog.testFlushAllChildren(); +} diff --git a/browser/components/urlbar/tests/ext/api.js b/browser/components/urlbar/tests/ext/api.js new file mode 100644 index 0000000000..77da790190 --- /dev/null +++ b/browser/components/urlbar/tests/ext/api.js @@ -0,0 +1,260 @@ +/* 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/. */ + +/* global ExtensionAPI, ExtensionCommon */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + UrlbarProviderExtension: + "resource:///modules/UrlbarProviderExtension.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +XPCOMUtils.defineLazyGetter( + this, + "defaultPreferences", + () => new Preferences({ defaultBranch: true }) +); + +let { EventManager } = ExtensionCommon; + +this.experiments_urlbar = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + urlbar: { + addDynamicResultType: (name, type) => { + this._addDynamicResultType(name, type); + }, + + addDynamicViewTemplate: (name, viewTemplate) => { + this._addDynamicViewTemplate(name, viewTemplate); + }, + + attributionURL: this._getDefaultSettingsAPI( + "browser.partnerlink.attributionURL" + ), + + clearInput() { + let window = BrowserWindowTracker.getTopWindow(); + window.gURLBar.value = ""; + window.gURLBar.setPageProxyState("invalid"); + }, + + engagementTelemetry: this._getDefaultSettingsAPI( + "browser.urlbar.eventTelemetry.enabled" + ), + + extensionTimeout: this._getDefaultSettingsAPI( + "browser.urlbar.extension.timeout" + ), + + onViewUpdateRequested: new EventManager({ + context, + name: "experiments.urlbar.onViewUpdateRequested", + register: (fire, providerName) => { + let provider = UrlbarProviderExtension.getOrCreate(providerName); + provider.setEventListener( + "getViewUpdate", + (result, idsByName) => { + return fire.async(result.payload, idsByName).catch(error => { + throw context.normalizeError(error); + }); + } + ); + return () => provider.setEventListener("getViewUpdate", null); + }, + }).api(), + }, + }, + }; + } + + onShutdown() { + // Reset the default prefs. This is necessary because + // ExtensionPreferencesManager doesn't properly reset prefs set on the + // default branch. See bug 1586543, bug 1578513, bug 1578508. + if (this._initialDefaultPrefs) { + for (let [pref, value] of this._initialDefaultPrefs.entries()) { + defaultPreferences.set(pref, value); + } + } + + this._removeDynamicViewTemplates(); + this._removeDynamicResultTypes(); + } + + _getDefaultSettingsAPI(pref) { + return { + get: details => { + return { + value: Preferences.get(pref), + + // Nothing actually uses this, but on debug builds there are extra + // checks enabled in Schema.sys.mjs that fail if it's not present. The + // value doesn't matter. + levelOfControl: "controllable_by_this_extension", + }; + }, + set: details => { + if (!this._initialDefaultPrefs) { + this._initialDefaultPrefs = new Map(); + } + if (!this._initialDefaultPrefs.has(pref)) { + this._initialDefaultPrefs.set(pref, defaultPreferences.get(pref)); + } + defaultPreferences.set(pref, details.value); + return true; + }, + clear: details => { + if (this._initialDefaultPrefs && this._initialDefaultPrefs.has(pref)) { + defaultPreferences.set(pref, this._initialDefaultPrefs.get(pref)); + return true; + } + return false; + }, + }; + } + + // We use the following four properties as bookkeeping to keep track of + // dynamic result types and view templates registered by extensions so that + // they can be properly removed on extension shutdown. + + // Names of dynamic result types added by this extension. + _dynamicResultTypeNames = new Set(); + + // Names of dynamic result type view templates added by this extension. + _dynamicViewTemplateNames = new Set(); + + // Maps dynamic result type names to Sets of IDs of extensions that have + // registered those types. + static extIDsByDynamicResultTypeName = new Map(); + + // Maps dynamic result type view template names to Sets of IDs of extensions + // that have registered those view templates. + static extIDsByDynamicViewTemplateName = new Map(); + + /** + * Adds a dynamic result type and includes it in our bookkeeping. See + * UrlbarResult.addDynamicResultType(). + * + * @param {string} name + * The name of the dynamic result type. + * @param {object} type + * The type. + */ + _addDynamicResultType(name, type) { + this._dynamicResultTypeNames.add(name); + this._addExtIDToDynamicResultTypeMap( + experiments_urlbar.extIDsByDynamicResultTypeName, + name + ); + UrlbarResult.addDynamicResultType(name, type); + } + + /** + * Removes all dynamic result types added by the extension. + */ + _removeDynamicResultTypes() { + for (let name of this._dynamicResultTypeNames) { + let allRemoved = this._removeExtIDFromDynamicResultTypeMap( + experiments_urlbar.extIDsByDynamicResultTypeName, + name + ); + if (allRemoved) { + UrlbarResult.removeDynamicResultType(name); + } + } + } + + /** + * Adds a dynamic result type view template and includes it in our + * bookkeeping. See UrlbarView.addDynamicViewTemplate(). + * + * @param {string} name + * The view template will be registered for the dynamic result type with + * this name. + * @param {object} viewTemplate + * The view template. + */ + _addDynamicViewTemplate(name, viewTemplate) { + this._dynamicViewTemplateNames.add(name); + this._addExtIDToDynamicResultTypeMap( + experiments_urlbar.extIDsByDynamicViewTemplateName, + name + ); + if (viewTemplate.stylesheet) { + viewTemplate.stylesheet = this.extension.baseURI.resolve( + viewTemplate.stylesheet + ); + } + UrlbarView.addDynamicViewTemplate(name, viewTemplate); + } + + /** + * Removes all dynamic result type view templates added by the extension. + */ + _removeDynamicViewTemplates() { + for (let name of this._dynamicViewTemplateNames) { + let allRemoved = this._removeExtIDFromDynamicResultTypeMap( + experiments_urlbar.extIDsByDynamicViewTemplateName, + name + ); + if (allRemoved) { + UrlbarView.removeDynamicViewTemplate(name); + } + } + } + + /** + * Adds a dynamic result type name and this extension's ID to a bookkeeping + * map. + * + * @param {Map} map + * Either extIDsByDynamicResultTypeName or extIDsByDynamicViewTemplateName. + * @param {string} dynamicTypeName + * The dynamic result type name. + */ + _addExtIDToDynamicResultTypeMap(map, dynamicTypeName) { + let extIDs = map.get(dynamicTypeName); + if (!extIDs) { + extIDs = new Set(); + map.set(dynamicTypeName, extIDs); + } + extIDs.add(this.extension.id); + } + + /** + * Removes a dynamic result type name and this extension's ID from a + * bookkeeping map. + * + * @param {Map} map + * Either extIDsByDynamicResultTypeName or extIDsByDynamicViewTemplateName. + * @param {string} dynamicTypeName + * The dynamic result type name. + * @returns {boolean} + * True if no other extension IDs are in the map under the same + * dynamicTypeName, and false otherwise. + */ + _removeExtIDFromDynamicResultTypeMap(map, dynamicTypeName) { + let extIDs = map.get(dynamicTypeName); + extIDs.delete(this.extension.id); + if (!extIDs.size) { + map.delete(dynamicTypeName); + return true; + } + return false; + } +}; diff --git a/browser/components/urlbar/tests/ext/browser/.eslintrc.js b/browser/components/urlbar/tests/ext/browser/.eslintrc.js new file mode 100644 index 0000000000..e57058ecb1 --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, +}; diff --git a/browser/components/urlbar/tests/ext/browser/browser.ini b/browser/components/urlbar/tests/ext/browser/browser.ini new file mode 100644 index 0000000000..416fc52eb3 --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser.ini @@ -0,0 +1,18 @@ +# 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/. + +[DEFAULT] +support-files = + ../../browser/head-common.js + ../api.js + ../schema.json + head.js + +[browser_ext_urlbar_attributionURL.js] +[browser_ext_urlbar_clearInput.js] +[browser_ext_urlbar_dynamicResult.js] +support-files = + dynamicResult.css +[browser_ext_urlbar_engagementTelemetry.js] +[browser_ext_urlbar_extensionTimeout.js] diff --git a/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_attributionURL.js b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_attributionURL.js new file mode 100644 index 0000000000..a5bccc8eba --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_attributionURL.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global browser */ + +// This tests the browser.experiments.urlbar.engagementTelemetry WebExtension +// Experiment API. + +"use strict"; + +add_settings_tasks("browser.partnerlink.attributionURL", "string", () => { + browser.test.onMessage.addListener(async (method, arg) => { + let result = await browser.experiments.urlbar.attributionURL[method](arg); + browser.test.sendMessage("done", result); + }); +}); diff --git a/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_clearInput.js b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_clearInput.js new file mode 100644 index 0000000000..afeff3b8a1 --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_clearInput.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global browser */ + +// This tests the browser.experiments.urlbar.clearInput WebExtension Experiment +// API. + +"use strict"; + +add_task(async function test() { + // Load a page so that pageproxystate is valid. When the extension calls + // clearInput, the pageproxystate should become invalid. + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + Assert.notEqual(gURLBar.value, "", "Input is not empty"); + Assert.equal(gURLBar.getAttribute("pageproxystate"), "valid"); + + let ext = await loadExtension({ + background: async () => { + await browser.experiments.urlbar.clearInput(); + browser.test.sendMessage("done"); + }, + }); + await ext.awaitMessage("done"); + + Assert.equal(gURLBar.value, "", "Input is empty"); + Assert.equal(gURLBar.getAttribute("pageproxystate"), "invalid"); + + await ext.unload(); + }); +}); diff --git a/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_dynamicResult.js b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_dynamicResult.js new file mode 100644 index 0000000000..a710d8949d --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_dynamicResult.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global browser */ + +// This tests dynamic results using the WebExtension Experiment API. + +"use strict"; + +add_task(async function test() { + let ext = await loadExtension({ + extraFiles: { + "dynamicResult.css": await ( + await fetch("file://" + getTestFilePath("dynamicResult.css")) + ).text(), + }, + background: async () => { + browser.experiments.urlbar.addDynamicResultType("testDynamicType"); + browser.experiments.urlbar.addDynamicViewTemplate("testDynamicType", { + stylesheet: "dynamicResult.css", + children: [ + { + name: "text", + tag: "span", + }, + { + name: "button", + tag: "span", + attributes: { + role: "button", + }, + }, + ], + }); + browser.urlbar.onBehaviorRequested.addListener(query => { + return "restricting"; + }, "test"); + browser.urlbar.onResultsRequested.addListener(query => { + return [ + { + type: "dynamic", + source: "local", + heuristic: true, + payload: { + dynamicType: "testDynamicType", + }, + }, + ]; + }, "test"); + browser.experiments.urlbar.onViewUpdateRequested.addListener(payload => { + return { + text: { + textContent: "This is a dynamic result.", + }, + button: { + textContent: "Click Me", + }, + }; + }, "test"); + browser.urlbar.onResultPicked.addListener((payload, elementName) => { + browser.test.sendMessage("onResultPicked", [payload, elementName]); + }, "test"); + }, + }); + + // Wait for the provider and dynamic type to be registered before continuing. + await TestUtils.waitForCondition( + () => + UrlbarProvidersManager.getProvider("test") && + UrlbarResult.getDynamicResultType("testDynamicType"), + "Waiting for provider and dynamic type to be registered" + ); + Assert.ok( + UrlbarProvidersManager.getProvider("test"), + "Provider should be registered" + ); + Assert.ok( + UrlbarResult.getDynamicResultType("testDynamicType"), + "Dynamic type should be registered" + ); + + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + waitForFocus: SimpleTest.waitForFocus, + }); + + // Get the row. + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 0); + Assert.equal( + row.result.type, + UrlbarUtils.RESULT_TYPE.DYNAMIC, + "row.result.type" + ); + Assert.equal( + row.getAttribute("dynamicType"), + "testDynamicType", + "row[dynamicType]" + ); + + let text = row.querySelector(".urlbarView-dynamic-testDynamicType-text"); + + // The view's call to provider.getViewUpdate is async, so we need to make sure + // the update has been applied before continuing to avoid intermittent + // failures. + await TestUtils.waitForCondition( + () => text.textContent == "This is a dynamic result." + ); + + // Check the elements. + Assert.equal( + text.textContent, + "This is a dynamic result.", + "text.textContent" + ); + let button = row.querySelector(".urlbarView-dynamic-testDynamicType-button"); + Assert.equal(button.textContent, "Click Me", "button.textContent"); + + // The result's button should be selected since the result is the heuristic. + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + button, + "Button should be selected" + ); + + // Pick the button. + let pickPromise = ext.awaitMessage("onResultPicked"); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeKey("KEY_Enter") + ); + let [payload, elementName] = await pickPromise; + Assert.equal(payload.dynamicType, "testDynamicType", "Picked payload"); + Assert.equal(elementName, "button", "Picked element name"); + + await ext.unload(); +}); diff --git a/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_engagementTelemetry.js b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_engagementTelemetry.js new file mode 100644 index 0000000000..50ded14d4e --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_engagementTelemetry.js @@ -0,0 +1,18 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global browser */ + +// This tests the browser.experiments.urlbar.engagementTelemetry WebExtension +// Experiment API. + +"use strict"; + +add_settings_tasks("browser.urlbar.eventTelemetry.enabled", "boolean", () => { + browser.test.onMessage.addListener(async (method, arg) => { + let result = await browser.experiments.urlbar.engagementTelemetry[method]( + arg + ); + browser.test.sendMessage("done", result); + }); +}); diff --git a/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_extensionTimeout.js b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_extensionTimeout.js new file mode 100644 index 0000000000..de09ef263c --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/browser_ext_urlbar_extensionTimeout.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* global browser */ + +// This tests the browser.experiments.urlbar.engagementTelemetry WebExtension +// Experiment API. + +"use strict"; + +add_settings_tasks("browser.urlbar.extension.timeout", "number", () => { + browser.test.onMessage.addListener(async (method, arg) => { + let result = await browser.experiments.urlbar.extensionTimeout[method](arg); + browser.test.sendMessage("done", result); + }); +}); diff --git a/browser/components/urlbar/tests/ext/browser/dynamicResult.css b/browser/components/urlbar/tests/ext/browser/dynamicResult.css new file mode 100644 index 0000000000..efd0c8c950 --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/dynamicResult.css @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +.urlbarView-row[dynamicType=testDynamicType] > .urlbarView-row-inner { + display: flex; + align-items: center; + min-height: 32px; + width: 100%; +} + +.urlbarView-dynamic-testDynamicType-text { + flex-grow: 1; + flex-shrink: 1; + padding: 10px; +} + +.urlbarView-dynamic-testDynamicType-button { + min-height: 16px; + padding: 8px; + border: none; + border-radius: 2px; + font-size: 0.93em; + color: inherit; + background-color: var(--urlbarView-button-background); + min-width: 8.75em; + text-align: center; + flex-basis: initial; + flex-shrink: 0; + margin-inline-end: 10px; +} + +.urlbarView-dynamic-testDynamicType-button[selected] { + color: white; + background-color: var(--urlbarView-primary-button-background); + box-shadow: 0 0 0 1px #0a84ff inset, 0 0 0 1px #0a84ff, 0 0 0 4px rgba(10, 132, 255, 0.3); +} diff --git a/browser/components/urlbar/tests/ext/browser/head.js b/browser/components/urlbar/tests/ext/browser/head.js new file mode 100644 index 0000000000..8d11a88066 --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/head.js @@ -0,0 +1,253 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * The files in this directory test the browser.urlbarExperiments WebExtension + * Experiment APIs, which are the WebExtension APIs we ship in our urlbar + * experiment extensions. Unlike the WebExtension APIs we ship in mozilla- + * central, which have continuous test coverage [1], our WebExtension Experiment + * APIs would not have continuous test coverage were it not for the fact that we + * copy and test them here. This is especially useful for APIs that are used in + * experiments that target multiple versions of Firefox, and for APIs that are + * reused in multiple experiments. See [2] and [3] for more info on + * experiments. + * + * [1] See browser/components/extensions/test + * [2] browser/components/urlbar/docs/experiments.rst + * [3] https://firefox-source-docs.mozilla.org/toolkit/components/extensions/webextensions/basics.html#webextensions-experiments + */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const SCHEMA_BASENAME = "schema.json"; +const SCRIPT_BASENAME = "api.js"; + +const SCHEMA_PATH = getTestFilePath(SCHEMA_BASENAME); +const SCRIPT_PATH = getTestFilePath(SCRIPT_BASENAME); + +let schemaSource; +let scriptSource; + +add_setup(async function loadSource() { + schemaSource = await (await fetch("file://" + SCHEMA_PATH)).text(); + scriptSource = await (await fetch("file://" + SCRIPT_PATH)).text(); +}); + +/** + * Loads a mock extension with our browser.experiments.urlbar API and a + * background script. Be sure to call `await ext.unload()` when you're done + * with it. + * + * @param {object} options + * Options object + * @param {Function} options.background + * This function is serialized and becomes the background script. + * @param {object} [options.extraFiles] + * Extra files to load in the extension. + * @returns {object} + * The extension. + */ +async function loadExtension({ background, extraFiles = {} }) { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["urlbar"], + experiment_apis: { + experiments_urlbar: { + schema: SCHEMA_BASENAME, + parent: { + scopes: ["addon_parent"], + paths: [["experiments", "urlbar"]], + script: SCRIPT_BASENAME, + }, + }, + }, + }, + files: { + [SCHEMA_BASENAME]: schemaSource, + [SCRIPT_BASENAME]: scriptSource, + ...extraFiles, + }, + isPrivileged: true, + background, + }); + await ext.startup(); + return ext; +} + +/** + * Tests toggling a preference value via an experiments.urlbar API. + * + * @param {string} prefName + * The name of the pref to be tested. + * @param {string} type + * The type of the pref being set. One of "string", "boolean", or "number". + * @param {Function} background + * Boilerplate function that returns the value from calling the + * browser.experiments.urlbar.prefName[method] APIs. + */ +function add_settings_tasks(prefName, type, background) { + let defaultPreferences = new Preferences({ defaultBranch: true }); + + let originalValue = defaultPreferences.get(prefName); + registerCleanupFunction(() => { + defaultPreferences.set(prefName, originalValue); + }); + + let firstValue, secondValue; + switch (type) { + case "string": + firstValue = "test value 1"; + secondValue = "test value 2"; + break; + case "number": + firstValue = 10; + secondValue = 100; + break; + case "boolean": + firstValue = false; + secondValue = true; + break; + default: + Assert.ok( + false, + `"type" parameter must be one of "string", "number", or "boolean"` + ); + } + + add_task(async function get() { + let ext = await loadExtension({ background }); + + defaultPreferences.set(prefName, firstValue); + ext.sendMessage("get", {}); + let result = await ext.awaitMessage("done"); + Assert.strictEqual(result.value, firstValue); + + defaultPreferences.set(prefName, secondValue); + ext.sendMessage("get", {}); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result.value, secondValue); + + await ext.unload(); + }); + + add_task(async function set() { + let ext = await loadExtension({ background }); + + defaultPreferences.set(prefName, firstValue); + ext.sendMessage("set", { value: secondValue }); + let result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), secondValue); + + ext.sendMessage("set", { value: firstValue }); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + + await ext.unload(); + }); + + add_task(async function clear() { + // no set() + defaultPreferences.set(prefName, firstValue); + let ext = await loadExtension({ background }); + ext.sendMessage("clear", {}); + let result = await ext.awaitMessage("done"); + Assert.strictEqual(result, false); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + await ext.unload(); + + // firstValue -> secondValue + defaultPreferences.set(prefName, firstValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: secondValue }); + await ext.awaitMessage("done"); + ext.sendMessage("clear", {}); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + await ext.unload(); + + // secondValue -> firstValue + defaultPreferences.set(prefName, secondValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: firstValue }); + await ext.awaitMessage("done"); + ext.sendMessage("clear", {}); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), secondValue); + await ext.unload(); + + // firstValue -> firstValue + defaultPreferences.set(prefName, firstValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: firstValue }); + await ext.awaitMessage("done"); + ext.sendMessage("clear", {}); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + await ext.unload(); + + // secondValue -> secondValue + defaultPreferences.set(prefName, secondValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: secondValue }); + await ext.awaitMessage("done"); + ext.sendMessage("clear", {}); + result = await ext.awaitMessage("done"); + Assert.strictEqual(result, true); + Assert.strictEqual(defaultPreferences.get(prefName), secondValue); + await ext.unload(); + }); + + add_task(async function shutdown() { + // no set() + defaultPreferences.set(prefName, firstValue); + let ext = await loadExtension({ background }); + await ext.unload(); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + + // firstValue -> secondValue + defaultPreferences.set(prefName, firstValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: secondValue }); + await ext.awaitMessage("done"); + await ext.unload(); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + + // secondValue -> firstValue + defaultPreferences.set(prefName, secondValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: firstValue }); + await ext.awaitMessage("done"); + await ext.unload(); + Assert.strictEqual(defaultPreferences.get(prefName), secondValue); + + // firstValue -> firstValue + defaultPreferences.set(prefName, firstValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: firstValue }); + await ext.awaitMessage("done"); + await ext.unload(); + Assert.strictEqual(defaultPreferences.get(prefName), firstValue); + + // secondValue -> secondValue + defaultPreferences.set(prefName, secondValue); + ext = await loadExtension({ background }); + ext.sendMessage("set", { value: secondValue }); + await ext.awaitMessage("done"); + await ext.unload(); + Assert.strictEqual(defaultPreferences.get(prefName), secondValue); + }); +} diff --git a/browser/components/urlbar/tests/ext/schema.json b/browser/components/urlbar/tests/ext/schema.json new file mode 100644 index 0000000000..ced5deddaa --- /dev/null +++ b/browser/components/urlbar/tests/ext/schema.json @@ -0,0 +1,113 @@ +[ + { + "namespace": "experiments.urlbar", + "description": "APIs supporting urlbar experiments", + "types": [ + { + "id": "DynamicResultType", + "type": "object", + "description": "Describes a dynamic result type.", + "properties": { + "viewTemplate": { + "type": "object", + "description": "An object describing the type's view.", + "additionalProperties": true + } + } + } + ], + "properties": { + "attributionURL": { + "$ref": "types.Setting", + "description": "Gets or sets the attribution URL for the current browser session." + }, + "engagementTelemetry": { + "$ref": "types.Setting", + "description": "Enables or disables the engagement telemetry for the current browser session." + }, + "extensionTimeout": { + "$ref": "types.Setting", + "description": "Sets the amount of time in ms that extensions have to return results to the browser.urlbar API." + } + }, + "events": [ + { + "name": "onViewUpdateRequested", + "type": "function", + "description": "Fired when the urlbar view updates the view of one of the results of the provider.", + "parameters": [ + { + "name": "payload", + "type": "object", + "description": "The result's payload." + }, + { + "name": "idsByName", + "type": "object", + "description": "A Map from an element's name, as defined by the provider; to its ID in the DOM, as defined by the browser." + } + ], + "extraParameters": [ + { + "name": "providerName", + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "The name of the provider you want to provide updates for." + } + ], + "returns": { + "type": "object", + "description": "An object describing the view update." + } + } + ], + "functions": [ + { + "name": "addDynamicResultType", + "type": "function", + "async": true, + "description": "Adds a dynamic result type. See UrlbarResult.addDynamicResultType().", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The name of the result type." + }, + { + "name": "type", + "type": "object", + "default": {}, + "optional": true, + "description": "The result type. Currently this should be an empty object (which is the default value)." + } + ] + }, + { + "name": "addDynamicViewTemplate", + "type": "function", + "async": true, + "description": "Adds a view template for a dynamic result type. See UrlbarView.addDynamicViewTemplate().", + "parameters": [ + { + "name": "name", + "type": "string", + "description": "The view template will be registered for the dynamic result type with this name." + }, + { + "name": "viewTemplate", + "type": "object", + "additionalProperties": true, + "description": "The view template." + } + ] + }, + { + "name": "clearInput", + "type": "function", + "async": true, + "description": "Sets urlbar.value to the empty string and the pageproxystate to invalid.", + "parameters": [] + } + ] + } +] diff --git a/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs new file mode 100644 index 0000000000..7763881b4f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs @@ -0,0 +1,765 @@ +/* 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", + 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.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.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(); diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs new file mode 100644 index 0000000000..34da73e847 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -0,0 +1,1017 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/valid-lazy */ + +import { + CONTEXTUAL_SERVICES_PING_TYPES, + PartnerLinkAttribution, +} from "resource:///modules/PartnerLinkAttribution.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +let gTestScope; + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "UrlbarTestUtils", { + get: () => { + if (!lazy._UrlbarTestUtils) { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._UrlbarTestUtils = null; + }); + lazy._UrlbarTestUtils = module; + } + return lazy._UrlbarTestUtils; + }, +}); + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "MerinoTestUtils", { + get: () => { + if (!lazy._MerinoTestUtils) { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._MerinoTestUtils = null; + }); + lazy._MerinoTestUtils = module; + } + return lazy._MerinoTestUtils; + }, +}); + +const DEFAULT_CONFIG = {}; + +const BEST_MATCH_CONFIG = { + best_match: { + blocked_suggestion_ids: [], + min_search_string_length: 4, + }, +}; + +const DEFAULT_PING_PAYLOADS = { + [CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + iab_category: "22 - Shopping", + improve_suggest_experience_checked: false, + match_type: "firefox-suggest", + request_id: null, + source: "remote-settings", + }, + [CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + improve_suggest_experience_checked: false, + match_type: "firefox-suggest", + reporting_url: "https://example.com/click", + request_id: null, + source: "remote-settings", + }, + [CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + improve_suggest_experience_checked: false, + is_clicked: false, + match_type: "firefox-suggest", + reporting_url: "https://example.com/impression", + request_id: null, + source: "remote-settings", + }, +}; + +// 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", +]; + +/** + * Mock RemoteSettings. + * + * @param {object} options + * Options object + * @param {object} options.config + * Dummy config in the RemoteSettings. + * @param {Array} options.data + * Dummy data in the RemoteSettings. + */ +class MockRemoteSettings { + constructor({ config = DEFAULT_CONFIG, data = [] }) { + this.#config = config; + this.#data = data; + + // Make a stub for "get" function to return dummy data. + const rs = lazy.RemoteSettings("quicksuggest"); + this.#sandbox = lazy.sinon.createSandbox(); + this.#sandbox.stub(rs, "get").callsFake(async query => { + return query.filters.type === "configuration" + ? [{ configuration: this.#config }] + : this.#data.filter(r => r.type === query.filters.type); + }); + + // Make a stub for "download" in attachments. + this.#sandbox.stub(rs.attachments, "download").callsFake(async record => { + if (!record.attachment) { + throw new Error("No attachmet in the record"); + } + const encoder = new TextEncoder(); + return { + buffer: encoder.encode(JSON.stringify(record.attachment)), + }; + }); + } + + async sync() { + if (!lazy.QuickSuggestRemoteSettings.rs) { + // There are no registered features that use remote settings. + return; + } + + // Observe config-set event to recognize that the config is synced. + const onConfigSync = new Promise(resolve => { + lazy.QuickSuggestRemoteSettings.emitter.once("config-set", resolve); + }); + + // Make a stub for each feature to recognize that the features are synced. + const features = lazy.QuickSuggestRemoteSettings.features; + const onFeatureSyncs = features.map(feature => { + return new Promise(resolve => { + const stub = this.#sandbox + .stub(feature, "onRemoteSettingsSync") + .callsFake(async (...args) => { + // Call and wait for the original function. + await stub.wrappedMethod.apply(feature, args); + stub.restore(); + resolve(); + }); + }); + }); + + // Force to sync. + const rs = lazy.RemoteSettings("quicksuggest"); + rs.emit("sync"); + + // Wait for sync. + await Promise.all([onConfigSync, ...onFeatureSyncs]); + } + + /* + * Update the config and data in RemoteSettings. If the config or the data are + * undefined, use the current one. + * + * @param {object} options + * Options object + * @param {object} options.config + * Dummy config in the RemoteSettings. + * @param {Array} options.data + * Dummy data in the RemoteSettings. + */ + async update({ config = this.#config, data = this.#data }) { + this.#config = config; + this.#data = data; + + await this.sync(); + } + + cleanup() { + this.#sandbox.restore(); + } + + #config = null; + #data = null; + #sandbox = null; +} + +/** + * Test utils for quick suggest. + */ +class _QuickSuggestTestUtils { + /** + * 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("QuickSuggestTestUtils() must be called with a scope"); + } + gTestScope = scope; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = scope[p]; + } + // If you add other properties to `this`, null them in `uninit()`. + + Services.telemetry.clearScalars(); + + scope.registerCleanupFunction?.(() => 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() { + gTestScope = null; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = null; + } + Services.telemetry.clearScalars(); + } + + get DEFAULT_CONFIG() { + // Return a clone so callers can modify it. + return Cu.cloneInto(DEFAULT_CONFIG, this); + } + + get BEST_MATCH_CONFIG() { + // Return a clone so callers can modify it. + return Cu.cloneInto(BEST_MATCH_CONFIG, this); + } + + /** + * Waits for quick suggest initialization to finish, ensures its data will not + * be updated again during the test, and also optionally sets it up with mock + * suggestions. + * + * @param {object} options + * Options object + * @param {Array} options.remoteSettingsResults + * Array of remote settings result objects. If not given, no suggestions + * will be present in remote settings. + * @param {Array} options.merinoSuggestions + * Array of Merino suggestion objects. If given, this function will start + * the mock Merino server and set `quicksuggest.dataCollection.enabled` to + * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it. + * Otherwise Merino will not serve suggestions, but you can still set up + * Merino without using this function by using `MerinoTestUtils` directly. + * @param {object} options.config + * The quick suggest configuration object. + * @returns {Function} + * A cleanup function. You only need to call this function if you're in a + * browser chrome test and you did not also call `init`. You can ignore it + * otherwise. + */ + async ensureQuickSuggestInit({ + remoteSettingsResults, + merinoSuggestions = null, + config = DEFAULT_CONFIG, + } = {}) { + this.#mockRemoteSettings = new MockRemoteSettings({ + config, + data: remoteSettingsResults, + }); + + this.info?.("ensureQuickSuggestInit calling QuickSuggest.init()"); + lazy.QuickSuggest.init(); + + // Sync with current data. + await this.#mockRemoteSettings.sync(); + + // Set up Merino. + if (merinoSuggestions) { + this.info?.("ensureQuickSuggestInit setting up Merino server"); + await lazy.MerinoTestUtils.server.start(); + lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions; + lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + this.info?.("ensureQuickSuggestInit done setting up Merino server"); + } + + let cleanup = async () => { + this.info?.("ensureQuickSuggestInit starting cleanup"); + this.#mockRemoteSettings.cleanup(); + if (merinoSuggestions) { + lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); + } + this.info?.("ensureQuickSuggestInit finished cleanup"); + }; + this.registerCleanupFunction?.(cleanup); + + return cleanup; + } + + /** + * Clears the current remote settings data and adds a new set of data. + * This can be used to add remote settings data after + * `ensureQuickSuggestInit()` has been called. + * + * @param {Array} data + * Array of remote settings data objects. + */ + async setRemoteSettingsResults(data) { + await this.#mockRemoteSettings.update({ data }); + } + + /** + * Sets the quick suggest configuration. You should call this again with + * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`. + * + * @param {object} config + * The config to be applied. See + */ + async setConfig(config) { + await this.#mockRemoteSettings.update({ config }); + } + + /** + * Sets the quick suggest configuration, calls your callback, and restores the + * previous configuration. + * + * @param {object} options + * The options object. + * @param {object} options.config + * The configuration that should be used with the callback + * @param {Function} options.callback + * Will be called with the configuration applied + * + * @see {@link setConfig} + */ + async withConfig({ config, callback }) { + let original = lazy.QuickSuggestRemoteSettings.config; + await this.setConfig(config); + await callback(); + await this.setConfig(original); + } + + /** + * Sets the Firefox Suggest scenario and waits for prefs to be updated. + * + * @param {string} scenario + * Pass falsey to reset the scenario to the default. + */ + async setScenario(scenario) { + // If we try to set the scenario before a previous update has finished, + // `updateFirefoxSuggestScenario` will bail, so wait. + await this.waitForScenarioUpdated(); + await lazy.UrlbarPrefs.updateFirefoxSuggestScenario({ scenario }); + } + + /** + * Waits for any prior scenario update to finish. + */ + async waitForScenarioUpdated() { + await lazy.TestUtils.waitForCondition( + () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario, + "Waiting for updatingFirefoxSuggestScenario to be false" + ); + } + + /** + * Asserts a result is a quick suggest result. + * + * @param {object} [options] + * The options object. + * @param {string} options.url + * The expected URL. At least one of `url` and `originalUrl` must be given. + * @param {string} options.originalUrl + * The expected original URL (the URL with an unreplaced timestamp + * template). At least one of `url` and `originalUrl` must be given. + * @param {object} options.window + * The window that should be used for this assertion + * @param {number} [options.index] + * The expected index of the quick suggest result. Pass -1 to use the index + * of the last result. + * @param {boolean} [options.isSponsored] + * Whether the result is expected to be sponsored. + * @param {boolean} [options.isBestMatch] + * Whether the result is expected to be a best match. + * @returns {result} + * The quick suggest result. + */ + async assertIsQuickSuggest({ + url, + originalUrl, + window, + index = -1, + isSponsored = true, + isBestMatch = false, + } = {}) { + this.Assert.ok( + url || originalUrl, + "At least one of url and originalUrl is specified" + ); + + if (index < 0) { + let resultCount = lazy.UrlbarTestUtils.getResultCount(window); + if (isBestMatch) { + index = 1; + this.Assert.greater( + resultCount, + 1, + "Sanity check: Result count should be > 1" + ); + } else { + index = resultCount - 1; + this.Assert.greater( + resultCount, + 0, + "Sanity check: Result count should be > 0" + ); + } + } + + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + let { result } = details; + + this.info?.( + `Checking actual result at index ${index}: ` + JSON.stringify(result) + ); + + this.Assert.equal( + result.providerName, + "UrlbarProviderQuickSuggest", + "Result provider name is UrlbarProviderQuickSuggest" + ); + this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL); + this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored"); + if (url) { + this.Assert.equal(details.url, url, "Result URL"); + } + if (originalUrl) { + this.Assert.equal( + result.payload.originalUrl, + originalUrl, + "Result original URL" + ); + } + + this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch"); + + let { row } = details.element; + + let sponsoredElement = isBestMatch + ? row._elements.get("bottom") + : row._elements.get("action"); + this.Assert.ok(sponsoredElement, "Result sponsored label element exists"); + this.Assert.equal( + sponsoredElement.textContent, + isSponsored ? "Sponsored" : "", + "Result sponsored label" + ); + + this.Assert.equal( + result.payload.helpUrl, + lazy.QuickSuggest.HELP_URL, + "Result helpURL" + ); + + if (lazy.UrlbarPrefs.get("resultMenu")) { + this.Assert.ok( + row._buttons.get("menu"), + "The menu button should be present" + ); + } else { + let helpButton = row._buttons.get("help"); + this.Assert.ok(helpButton, "The help button should be present"); + + let blockButton = row._buttons.get("block"); + if (!isBestMatch) { + this.Assert.equal( + !!blockButton, + lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled"), + "The block button is present iff quick suggest blocking is enabled" + ); + } else { + this.Assert.equal( + !!blockButton, + lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"), + "The block button is present iff best match blocking is enabled" + ); + } + } + + return details; + } + + /** + * Asserts a result is not a quick suggest result. + * + * @param {object} window + * The window that should be used for this assertion + * @param {number} index + * The index of the result. + */ + async assertIsNotQuickSuggest(window, index) { + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + this.Assert.notEqual( + details.result.providerName, + "UrlbarProviderQuickSuggest", + `Result at index ${index} is not provided by UrlbarProviderQuickSuggest` + ); + } + + /** + * Asserts that none of the results are quick suggest results. + * + * @param {object} window + * The window that should be used for this assertion + */ + async assertNoQuickSuggestResults(window) { + for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) { + await this.assertIsNotQuickSuggest(window, i); + } + } + + /** + * Checks the values of all the quick suggest telemetry keyed scalars and, + * if provided, other non-quick-suggest keyed scalars. Scalar values are all + * assumed to be 1. + * + * @param {object} expectedKeysByScalarName + * Maps scalar names to keys that are expected to be recorded. The value for + * each key is assumed to be 1. If you expect a scalar to be incremented, + * include it in this object; otherwise, don't include it. + */ + assertScalars(expectedKeysByScalarName) { + let scalars = lazy.TelemetryTestUtils.getProcessScalars( + "parent", + true, + true + ); + + // Check all quick suggest scalars. + expectedKeysByScalarName = { ...expectedKeysByScalarName }; + for (let scalarName of Object.values( + lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS + )) { + if (scalarName in expectedKeysByScalarName) { + lazy.TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + expectedKeysByScalarName[scalarName], + 1 + ); + delete expectedKeysByScalarName[scalarName]; + } else { + this.Assert.ok( + !(scalarName in scalars), + "Scalar should not be present: " + scalarName + ); + } + } + + // Check any other remaining scalars that were passed in. + for (let [scalarName, key] of Object.entries(expectedKeysByScalarName)) { + lazy.TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, 1); + } + } + + /** + * Checks quick suggest telemetry events. This is the same as + * `TelemetryTestUtils.assertEvents()` except it filters in only quick suggest + * events by default. If you are expecting events that are not in the quick + * suggest category, use `TelemetryTestUtils.assertEvents()` directly or pass + * in a filter override for `category`. + * + * @param {Array} expectedEvents + * List of expected telemetry events. + * @param {object} filterOverrides + * Extra properties to set in the filter object. + * @param {object} options + * The options object to pass to `TelemetryTestUtils.assertEvents()`. + */ + assertEvents(expectedEvents, filterOverrides = {}, options = undefined) { + lazy.TelemetryTestUtils.assertEvents( + expectedEvents, + { + category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + ...filterOverrides, + }, + options + ); + } + + /** + * Creates a `sinon.sandbox` and `sinon.spy` that can be used to instrument + * the quick suggest custom telemetry pings. If `init` was called with a test + * scope where `registerCleanupFunction` is defined, the sandbox will + * automically be restored at the end of the test. + * + * @returns {object} + * An object: { sandbox, spy, spyCleanup } + * `spyCleanup` is a cleanup function that should be called if you're in a + * browser chrome test and you did not also call `init`, or if you need to + * remove the spy before the test ends for some other reason. You can ignore + * it otherwise. + */ + createTelemetryPingSpy() { + let sandbox = lazy.sinon.createSandbox(); + let spy = sandbox.spy( + PartnerLinkAttribution._pingCentre, + "sendStructuredIngestionPing" + ); + let spyCleanup = () => sandbox.restore(); + this.registerCleanupFunction?.(spyCleanup); + return { sandbox, spy, spyCleanup }; + } + + /** + * Asserts that custom telemetry pings are recorded in the order they appear + * in the given `pings` array and that no other pings are recorded. + * + * @param {object} spy + * A `sinon.spy` object. See `createTelemetryPingSpy()`. This method resets + * the spy before returning. + * @param {Array} pings + * The expected pings in the order they are expected to be recorded. Each + * item in this array should be an object: `{ type, payload }` + * + * {string} type + * The ping's expected type, one of the `CONTEXTUAL_SERVICES_PING_TYPES` + * values. + * {object} payload + * The ping's expected payload. For convenience, you can leave out + * properties whose values are expected to be the default values defined + * in `DEFAULT_PING_PAYLOADS`. + */ + assertPings(spy, pings) { + let calls = spy.getCalls(); + this.Assert.equal( + calls.length, + pings.length, + "Expected number of ping calls" + ); + + for (let i = 0; i < pings.length; i++) { + let ping = pings[i]; + this.info?.( + `Checking ping at index ${i}, expected is: ` + JSON.stringify(ping) + ); + + // Add default properties to the expected payload for any that aren't + // already defined. + let { type, payload } = ping; + let defaultPayload = DEFAULT_PING_PAYLOADS[type]; + this.Assert.ok( + defaultPayload, + `Sanity check: Default payload exists for type: ${type}` + ); + payload = { ...defaultPayload, ...payload }; + + // Check the endpoint URL. + let call = calls[i]; + let endpointURL = call.args[1]; + this.Assert.ok( + endpointURL.includes(type), + `Endpoint URL corresponds to the expected ping type: ${type}` + ); + + // Check the payload. + let actualPayload = call.args[0]; + this._assertPingPayload(actualPayload, payload); + } + + spy.resetHistory(); + } + + /** + * Helper for checking contextual services ping payloads. + * + * @param {object} actualPayload + * The actual payload in the ping. + * @param {object} expectedPayload + * An object describing the expected payload. Non-function values in this + * object are checked for equality against the corresponding actual payload + * values. Function values are called and passed the corresponding actual + * values and should return true if the actual values are correct. + */ + _assertPingPayload(actualPayload, expectedPayload) { + this.info?.( + "Checking ping payload. Actual: " + + JSON.stringify(actualPayload) + + " -- Expected (excluding function properties): " + + JSON.stringify(expectedPayload) + ); + + this.Assert.equal( + Object.entries(actualPayload).length, + Object.entries(expectedPayload).length, + "Payload has expected number of properties" + ); + + for (let [key, expectedValue] of Object.entries(expectedPayload)) { + let actualValue = actualPayload[key]; + if (typeof expectedValue == "function") { + this.Assert.ok(expectedValue(actualValue), "Payload property: " + key); + } else { + this.Assert.equal( + actualValue, + expectedValue, + "Payload property: " + key + ); + } + } + } + + /** + * Asserts that URLs in a result's payload have the timestamp template + * substring replaced with real timestamps. + * + * @param {UrlbarResult} result The results to check + * @param {object} urls + * An object that contains the expected payload properties with template + * substrings. For example: + * ```js + * { + * url: "http://example.com/foo-%YYYYMMDDHH%", + * sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%", + * } + * ``` + */ + assertTimestampsReplaced(result, urls) { + let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.QuickSuggest; + + // Parse the timestamp strings from each payload property and save them in + // `urls[key].timestamp`. + urls = { ...urls }; + for (let [key, url] of Object.entries(urls)) { + let index = url.indexOf(TIMESTAMP_TEMPLATE); + this.Assert.ok( + index >= 0, + `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}` + ); + let value = result.payload[key]; + this.Assert.ok(value, "Key is in result payload: " + key); + let timestamp = value.substring(index, index + TIMESTAMP_LENGTH); + + // Set `urls[key]` to an object that's helpful in the logged info message + // below. + urls[key] = { url, value, timestamp }; + } + + this.info?.("Parsed timestamps: " + JSON.stringify(urls)); + + // Make a set of unique timestamp strings. There should only be one. + let { timestamp } = Object.values(urls)[0]; + this.Assert.deepEqual( + [...new Set(Object.values(urls).map(o => o.timestamp))], + [timestamp], + "There's only one unique timestamp string" + ); + + // Parse the parts of the timestamp string. + let year = timestamp.slice(0, -6); + let month = timestamp.slice(-6, -4); + let day = timestamp.slice(-4, -2); + let hour = timestamp.slice(-2); + let date = new Date(year, month - 1, day, hour); + + // The timestamp should be no more than two hours in the past. Typically it + // will be the same as the current hour, but since its resolution is in + // terms of hours and it's possible the test may have crossed over into a + // new hour as it was running, allow for the previous hour. + this.Assert.less( + Date.now() - 2 * 60 * 60 * 1000, + date.getTime(), + "Timestamp is within the past two hours" + ); + } + + /** + * Calls a callback while enrolled in a mock Nimbus experiment. The experiment + * is automatically unenrolled and cleaned up after the callback returns. + * + * @param {object} options + * Options for the mock experiment. + * @param {Function} options.callback + * The callback to call while enrolled in the mock experiment. + * @param {object} options.options + * See {@link enrollExperiment}. + */ + async withExperiment({ callback, ...options }) { + let doExperimentCleanup = await this.enrollExperiment(options); + await callback(); + await doExperimentCleanup(); + } + + /** + * Enrolls in a mock Nimbus experiment. + * + * @param {object} options + * Options for the mock experiment. + * @param {object} [options.valueOverrides] + * Values for feature variables. + * @returns {Promise<Function>} + * The experiment cleanup function (async). + */ + async enrollExperiment({ valueOverrides = {} }) { + this.info?.("Awaiting ExperimentAPI.ready"); + await lazy.ExperimentAPI.ready(); + + // Wait for any prior scenario updates to finish. If updates are ongoing, + // UrlbarPrefs will ignore the Nimbus update when the experiment is + // installed. This shouldn't be a problem in practice because in reality + // scenario updates are triggered only on app startup and Nimbus + // enrollments, but tests can trigger lots of updates back to back. + await this.waitForScenarioUpdated(); + + let doExperimentCleanup = + await lazy.ExperimentFakes.enrollWithFeatureConfig({ + enabled: true, + featureId: "urlbar", + value: valueOverrides, + }); + + // Wait for the pref updates triggered by the experiment enrollment. + this.info?.("Awaiting update after enrolling in experiment"); + await this.waitForScenarioUpdated(); + + return async () => { + this.info?.("Awaiting experiment cleanup"); + await doExperimentCleanup(); + + // The same pref updates will be triggered by unenrollment, so wait for + // them again. + this.info?.("Awaiting update after unenrolling in experiment"); + await this.waitForScenarioUpdated(); + }; + } + + /** + * Clears the Nimbus exposure event. + */ + async clearExposureEvent() { + // Exposure event recording is queued to the idle thread, so wait for idle + // before we start so any events from previous tasks will have been recorded + // and won't interfere with this task. + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + + Services.telemetry.clearEvents(); + lazy.NimbusFeatures.urlbar._didSendExposureEvent = false; + lazy.QuickSuggest._recordedExposureEvent = false; + } + + /** + * Asserts the Nimbus exposure event is recorded or not as expected. + * + * @param {boolean} expectedRecorded + * Whether the event is expected to be recorded. + */ + async assertExposureEvent(expectedRecorded) { + this.Assert.equal( + lazy.QuickSuggest._recordedExposureEvent, + expectedRecorded, + "_recordedExposureEvent is correct" + ); + + let filter = { + category: "normandy", + method: "expose", + object: "nimbus_experiment", + }; + + let expectedEvents = []; + if (expectedRecorded) { + expectedEvents.push({ + ...filter, + extra: { + branchSlug: "control", + featureId: "urlbar", + }, + }); + } + + // The event recording is queued to the idle thread when the search starts, + // so likewise queue the assert to idle instead of doing it immediately. + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(() => { + lazy.TelemetryTestUtils.assertEvents(expectedEvents, filter); + resolve(); + }); + }); + } + + /** + * Sets the app's locales, calls your callback, and resets locales. + * + * @param {Array} locales + * An array of locale strings. The entire array will be set as the available + * locales, and the first locale in the array will be set as the requested + * locale. + * @param {Function} callback + * The callback to be called with the {@link locales} set. This function can + * be async. + */ + async withLocales(locales, callback) { + let promiseChanges = async desiredLocales => { + this.info?.( + "Changing locales from " + + JSON.stringify(Services.locale.requestedLocales) + + " to " + + JSON.stringify(desiredLocales) + ); + + if (desiredLocales[0] == Services.locale.requestedLocales[0]) { + // Nothing happens when the locale doesn't actually change. + return; + } + + this.info?.("Waiting for intl:requested-locales-changed"); + await lazy.TestUtils.topicObserved("intl:requested-locales-changed"); + this.info?.("Observed intl:requested-locales-changed"); + + // Wait for the search service to reload engines. Otherwise tests can fail + // in strange ways due to internal search service state during shutdown. + // It won't always reload engines but it's hard to tell in advance when it + // won't, so also set a timeout. + this.info?.("Waiting for TOPIC_SEARCH_SERVICE"); + await Promise.race([ + lazy.TestUtils.topicObserved( + lazy.SearchUtils.TOPIC_SEARCH_SERVICE, + (subject, data) => { + this.info?.("Observed TOPIC_SEARCH_SERVICE with data: " + data); + return data == "engines-reloaded"; + } + ), + new Promise(resolve => { + lazy.setTimeout(() => { + this.info?.("Timed out waiting for TOPIC_SEARCH_SERVICE"); + resolve(); + }, 2000); + }), + ]); + + this.info?.("Done waiting for locale changes"); + }; + + let available = Services.locale.availableLocales; + let requested = Services.locale.requestedLocales; + + let newRequested = locales.slice(0, 1); + let promise = promiseChanges(newRequested); + Services.locale.availableLocales = locales; + Services.locale.requestedLocales = newRequested; + await promise; + + this.Assert.equal( + Services.locale.appLocaleAsBCP47, + locales[0], + "App locale is now " + locales[0] + ); + + await callback(); + + promise = promiseChanges(requested); + Services.locale.availableLocales = available; + Services.locale.requestedLocales = requested; + await promise; + } + + #mockRemoteSettings = null; +} + +export var QuickSuggestTestUtils = new _QuickSuggestTestUtils(); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.ini b/browser/components/urlbar/tests/quicksuggest/browser/browser.ini new file mode 100644 index 0000000000..a29ef67770 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser.ini @@ -0,0 +1,37 @@ +# 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/. + +[DEFAULT] +support-files = + head.js + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + subdialog.xhtml + +[browser_quicksuggest.js] +[browser_quicksuggest_addons.js] +[browser_quicksuggest_block.js] +[browser_quicksuggest_configuration.js] +[browser_quicksuggest_indexes.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_quicksuggest_merinoSessions.js] +[browser_quicksuggest_onboardingDialog.js] +skip-if = + os == 'linux' && bits == 64 # Bug 1773830 +[browser_telemetry_dynamicWikipedia.js] +tags = search-telemetry +[browser_telemetry_impressionEdgeCases.js] +tags = search-telemetry +[browser_telemetry_navigationalSuggestions.js] +tags = search-telemetry +[browser_telemetry_nonsponsored.js] +tags = search-telemetry +[browser_telemetry_other.js] +tags = search-telemetry +[browser_telemetry_sponsored.js] +tags = search-telemetry +[browser_telemetry_weather.js] +tags = search-telemetry +[browser_weather.js] diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js new file mode 100644 index 0000000000..d11f6d6386 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests browser quick suggestions. + */ + +const TEST_URL = "http://example.com/quicksuggest"; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: `${TEST_URL}?q=frabbits`, + title: "frabbits", + keywords: ["fra", "frab"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + }, + { + id: 2, + url: `${TEST_URL}?q=nonsponsored`, + title: "Non-Sponsored", + keywords: ["nonspon"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Tests a sponsored result and keyword highlighting. +add_task(async function sponsored() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "fra", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: true, + url: `${TEST_URL}?q=frabbits`, + }); + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1); + Assert.equal( + row.querySelector(".urlbarView-title").firstChild.textContent, + "fra", + "The part of the keyword that matches users input is not bold." + ); + Assert.equal( + row.querySelector(".urlbarView-title > strong").textContent, + "b", + "The auto completed section of the keyword is bolded." + ); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// Tests a non-sponsored result. +add_task(async function nonSponsored() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "nonspon", + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index: 1, + isSponsored: false, + url: `${TEST_URL}?q=nonsponsored`, + }); + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js new file mode 100644 index 0000000000..e0b75d0e9b --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js @@ -0,0 +1,560 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for addon suggestions. + +const TEST_MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "https://example.com/first.svg", + url: "https://example.com/first-addon", + title: "First Addon", + description: "This is a first addon", + custom_details: { + amo: { + rating: "5", + number_of_ratings: "1234567", + guid: "first@addon", + }, + }, + is_top_pick: true, + }, + { + provider: "amo", + icon: "https://example.com/second.png", + url: "https://example.com/second-addon", + title: "Second Addon", + description: "This is a second addon", + custom_details: { + amo: { + rating: "4.5", + number_of_ratings: "123", + guid: "second@addon", + }, + }, + is_sponsored: true, + is_top_pick: false, + }, + { + provider: "amo", + icon: "https://example.com/third.svg", + url: "https://example.com/third-addon", + title: "Third Addon", + description: "This is a third addon", + custom_details: { + amo: { + rating: "0", + number_of_ratings: "0", + guid: "third@addon", + }, + }, + is_top_pick: false, + }, + { + provider: "amo", + icon: "https://example.com/fourth.svg", + url: "https://example.com/fourth-addon", + title: "Fourth Addon", + description: "This is a fourth addon", + custom_details: { + amo: { + rating: "4", + number_of_ratings: "4", + guid: "fourth@addon", + }, + }, + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.quicksuggest.enabled", true], + ["browser.urlbar.quicksuggest.remoteSettings.enabled", false], + ["browser.urlbar.bestMatch.enabled", true], + ], + }); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: TEST_MERINO_SUGGESTIONS, + }); +}); + +add_task(async function basic() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", true]], + }); + + for (const merinoSuggestion of TEST_MERINO_SUGGESTIONS) { + MerinoTestUtils.server.response.body.suggestions = [merinoSuggestion]; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + const row = element.row; + const icon = row.querySelector(".urlbarView-dynamic-addons-icon"); + Assert.equal(icon.src, merinoSuggestion.icon); + const url = row.querySelector(".urlbarView-dynamic-addons-url"); + Assert.equal(url.textContent, merinoSuggestion.url); + const title = row.querySelector(".urlbarView-dynamic-addons-title"); + Assert.equal(title.textContent, merinoSuggestion.title); + const description = row.querySelector( + ".urlbarView-dynamic-addons-description" + ); + Assert.equal(description.textContent, merinoSuggestion.description); + const reviews = row.querySelector(".urlbarView-dynamic-addons-reviews"); + Assert.equal( + reviews.textContent, + `${new Intl.NumberFormat().format( + Number(merinoSuggestion.custom_details.amo.number_of_ratings) + )} reviews` + ); + + const isTopPick = merinoSuggestion.is_top_pick ?? true; + if (isTopPick) { + Assert.equal(result.suggestedIndex, 1); + } else if (merinoSuggestion.is_sponsored) { + Assert.equal( + result.suggestedIndex, + UrlbarPrefs.get("quickSuggestSponsoredIndex") + ); + } else { + Assert.equal( + result.suggestedIndex, + UrlbarPrefs.get("quickSuggestNonSponsoredIndex") + ); + } + + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + merinoSuggestion.url + ); + EventUtils.synthesizeMouseAtCenter(row, {}); + await onLoad; + Assert.ok(true, "Expected page is loaded"); + + await PlacesUtils.history.clear(); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function ratings() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", true]], + }); + + const testRating = [ + "0", + "0.24", + "0.25", + "0.74", + "0.75", + "1", + "1.24", + "1.25", + "1.74", + "1.75", + "2", + "2.24", + "2.25", + "2.74", + "2.75", + "3", + "3.24", + "3.25", + "3.74", + "3.75", + "4", + "4.24", + "4.25", + "4.74", + "4.75", + "5", + ]; + const baseMerinoSuggestion = JSON.parse( + JSON.stringify(TEST_MERINO_SUGGESTIONS[0]) + ); + + for (const rating of testRating) { + baseMerinoSuggestion.custom_details.amo.rating = rating; + MerinoTestUtils.server.response.body.suggestions = [baseMerinoSuggestion]; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + const ratingElements = element.row.querySelectorAll( + ".urlbarView-dynamic-addons-rating" + ); + Assert.equal(ratingElements.length, 5); + + for (let i = 0; i < ratingElements.length; i++) { + const ratingElement = ratingElements[i]; + + const distanceToFull = Number(rating) - i; + let fill = "full"; + if (distanceToFull < 0.25) { + fill = "empty"; + } else if (distanceToFull < 0.75) { + fill = "half"; + } + Assert.equal(ratingElement.getAttribute("fill"), fill); + } + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function disable() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", false]], + }); + + // Restore AdmWikipedia suggestions. + MerinoTestUtils.server.reset(); + // Add one Addon suggestion that is higher score than AdmWikipedia. + MerinoTestUtils.server.response.body.suggestions.push( + Object.assign({}, TEST_MERINO_SUGGESTIONS[0], { score: 2 }) + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.payload.telemetryType, "adm_sponsored"); + + MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_SUGGESTIONS; + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function resultMenu_showLessFrequently() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.addons.featureGate", true], + ["browser.urlbar.addons.showLessFrequentlyCount", 0], + ], + }); + + const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + addonsShowLessFrequentlyCap: 3, + }); + + // Sanity check. + Assert.equal(UrlbarPrefs.get("addonsShowLessFrequentlyCap"), 3); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 0); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 1); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 2); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + isSuggestionShown: true, + isMenuItemShown: true, + }, + }); + Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 3); + + await doShowLessFrequently({ + input: "aaa b", + expected: { + // The suggestion should not display since addons.showLessFrequentlyCount + // is 3 and the substring (" b") after the first word ("aaa") is 2 chars + // long. + isSuggestionShown: false, + }, + }); + + await doShowLessFrequently({ + input: "aaa bb", + expected: { + // The suggestion should display, but item should not shown since the + // addons.showLessFrequentlyCount reached to addonsShowLessFrequentlyCap + // already. + isSuggestionShown: true, + isMenuItemShown: false, + }, + }); + + await cleanUpNimbus(); + await SpecialPowers.popPrefEnv(); +}); + +// Tests the "Not interested" result menu dismissal command. +add_task(async function resultMenu_notInterested() { + await doDismissTest("not_interested"); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_task(async function notRelevant() { + await doDismissTest("not_relevant"); +}); + +add_task(async function rowLabel() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", true]], + }); + + const testCases = [ + { + bestMatch: true, + expected: "Firefox extension", + }, + { + bestMatch: false, + expected: "Firefox Suggest", + }, + ]; + + for (const { bestMatch, expected } of testCases) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", bestMatch]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + Assert.equal(row.getAttribute("label"), expected); + + await SpecialPowers.popPrefEnv(); + } + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function treatmentB() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", true]], + }); + + const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + addonsUITreatment: "b", + }); + // Sanity check. + Assert.equal(UrlbarPrefs.get("addonsUITreatment"), "b"); + + const merinoSuggestion = TEST_MERINO_SUGGESTIONS[0]; + MerinoTestUtils.server.response.body.suggestions = [merinoSuggestion]; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + + const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + const row = element.row; + const icon = row.querySelector(".urlbarView-dynamic-addons-icon"); + Assert.equal(icon.src, merinoSuggestion.icon); + const url = row.querySelector(".urlbarView-dynamic-addons-url"); + Assert.equal(url.textContent, merinoSuggestion.url); + const title = row.querySelector(".urlbarView-dynamic-addons-title"); + Assert.equal(title.textContent, merinoSuggestion.title); + const description = row.querySelector( + ".urlbarView-dynamic-addons-description" + ); + Assert.equal(description.textContent, merinoSuggestion.description); + const ratingContainer = row.querySelector( + ".urlbarView-dynamic-addons-ratingContainer" + ); + Assert.ok(BrowserTestUtils.is_hidden(ratingContainer)); + const reviews = row.querySelector(".urlbarView-dynamic-addons-reviews"); + Assert.equal(reviews.textContent, "Recommended"); + + await cleanUpNimbus(); + await SpecialPowers.popPrefEnv(); +}); + +async function doShowLessFrequently({ input, expected }) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: input, + }); + + if (!expected.isSuggestionShown) { + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + const details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.result.payload.dynamicType, + "addons", + `Addons suggestion should be absent (checking index ${i})` + ); + } + + return; + } + + const resultIndex = 1; + const details = await UrlbarTestUtils.getDetailsOfResultAt( + window, + resultIndex + ); + Assert.equal( + details.result.payload.dynamicType, + "addons", + `Addons suggestion should be present at expected index after ${input} search` + ); + + // Click the command. + try { + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + "show_less_frequently", + { + resultIndex, + } + ); + Assert.ok(expected.isMenuItemShown); + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + } catch (e) { + Assert.ok(!expected.isMenuItemShown); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after clicking command" + ); + Assert.equal( + e.message, + "Menu item not found for command: show_less_frequently" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); +} + +async function doDismissTest(command) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.addons.featureGate", true]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "123", + }); + + const resultCount = UrlbarTestUtils.getResultCount(window); + const resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.payload.dynamicType, + "addons", + "Addons suggestion should be present" + ); + + // Sanity check. + Assert.ok(UrlbarPrefs.get("suggest.addons")); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command], + { resultIndex, openByMouse: true } + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.addons"), + "suggest.addons pref should be set to false after dismissal" + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + "0", + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.payload.dynamicType !== "addons", + "Tip result and addon result should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + await SpecialPowers.popPrefEnv(); + UrlbarPrefs.clear("suggest.addons"); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js new file mode 100644 index 0000000000..081818c02b --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js @@ -0,0 +1,445 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests blocking quick suggest results, including best matches. See also: +// +// browser_bestMatch.js +// Includes tests for blocking best match rows independent of quick suggest, +// especially the superficial UI part that should be common to all types of +// best matches + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; +const { TIMESTAMP_TEMPLATE } = QuickSuggest; + +// Include the timestamp template in the suggestion URLs so we can make sure +// their original URLs with the unreplaced templates are blocked and not their +// URLs with timestamps. +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: `https://example.com/sponsored?t=${TIMESTAMP_TEMPLATE}`, + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: `https://example.com/nonsponsored?t=${TIMESTAMP_TEMPLATE}`, + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "5 - Education", + }, +]; + +// Spy for the custom impression/click sender +let spy; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.bestMatch.blockingEnabled", true], + ["browser.urlbar.quicksuggest.blockingEnabled", true], + ], + }); + + ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy()); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + await QuickSuggest.blockedSuggestions._test_readyPromise; + await QuickSuggest.blockedSuggestions.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + config: QuickSuggestTestUtils.BEST_MATCH_CONFIG, + }); +}); + +/** + * Adds a test task that runs the given callback with combinations of the + * following: + * + * - Best match disabled and enabled + * - Each result in `REMOTE_SETTINGS_RESULTS` + * + * @param {Function} fn + * The callback function. It's passed: `{ isBestMatch, suggestion }` + */ +function add_combo_task(fn) { + let taskFn = async () => { + for (let isBestMatch of [false, true]) { + UrlbarPrefs.set("bestMatch.enabled", isBestMatch); + for (let result of REMOTE_SETTINGS_RESULTS) { + info(`Running ${fn.name}: ${JSON.stringify({ isBestMatch, result })}`); + await fn({ isBestMatch, result }); + } + UrlbarPrefs.clear("bestMatch.enabled"); + } + }; + Object.defineProperty(taskFn, "name", { value: fn.name }); + add_task(taskFn); +} + +// Picks the block button with the keyboard. +add_combo_task(async function basic_keyboard({ result, isBestMatch }) { + await doBasicBlockTest({ + result, + isBestMatch, + block: async () => { + if (UrlbarPrefs.get("resultMenu")) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 1, + }); + } else { + // TAB twice to select the block button: once to select the main + // part of the row, once to select the block button. + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + EventUtils.synthesizeKey("KEY_Enter"); + } + }, + }); +}); + +// Picks the block button with the mouse. +add_combo_task(async function basic_mouse({ result, isBestMatch }) { + await doBasicBlockTest({ + result, + isBestMatch, + block: async () => { + if (UrlbarPrefs.get("resultMenu")) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 1, + openByMouse: true, + }); + } else { + EventUtils.synthesizeMouseAtCenter( + UrlbarTestUtils.getButtonForResultIndex(window, "block", 1), + {} + ); + } + }, + }); +}); + +// Uses the key shortcut to block a suggestion. +add_combo_task(async function basic_keyShortcut({ result, isBestMatch }) { + await doBasicBlockTest({ + result, + isBestMatch, + block: () => { + // Arrow down once to select the row. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + }, + }); +}); + +async function doBasicBlockTest({ result, isBestMatch, block }) { + spy.resetHistory(); + + // Do a search that triggers the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: result.keywords[0], + }); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 2, + "Two rows are present after searching (heuristic + suggestion)" + ); + + let isSponsored = result.keywords[0] == "sponsored"; + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isBestMatch, + isSponsored, + originalUrl: result.url, + }); + + // Block the suggestion. + await block(); + + // The row should have been removed. + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "View remains open after blocking result" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Only one row after blocking suggestion" + ); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + + // The URL should be blocked. + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.url), + "Suggestion is blocked" + ); + + // Check telemetry scalars. + let index = 2; + let scalars = {}; + if (isSponsored) { + scalars[TELEMETRY_SCALARS.IMPRESSION_SPONSORED] = index; + scalars[TELEMETRY_SCALARS.BLOCK_SPONSORED] = index; + } else { + scalars[TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED] = index; + scalars[TELEMETRY_SCALARS.BLOCK_NONSPONSORED] = index; + } + if (isBestMatch) { + if (isSponsored) { + scalars = { + ...scalars, + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: index, + [TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH]: index, + }; + } else { + scalars = { + ...scalars, + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: index, + [TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH]: index, + }; + } + } + QuickSuggestTestUtils.assertScalars(scalars); + + // Check the engagement event. + let match_type = isBestMatch ? "best-match" : "firefox-suggest"; + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + match_type, + position: String(index), + suggestion_type: isSponsored ? "sponsored" : "nonsponsored", + }, + }, + ]); + + // Check the custom telemetry pings. + QuickSuggestTestUtils.assertPings(spy, [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + block_id: result.id, + is_clicked: false, + position: index, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + match_type, + block_id: result.id, + iab_category: result.iab_category, + position: index, + }, + }, + ]); + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggest.blockedSuggestions.clear(); +} + +// Blocks multiple suggestions one after the other. +add_task(async function blockMultiple() { + for (let isBestMatch of [false, true]) { + UrlbarPrefs.set("bestMatch.enabled", isBestMatch); + info(`Testing with best match enabled: ${isBestMatch}`); + + for (let i = 0; i < REMOTE_SETTINGS_RESULTS.length; i++) { + // Do a search that triggers the i'th suggestion. + let { keywords, url } = REMOTE_SETTINGS_RESULTS[i]; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: keywords[0], + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isBestMatch, + originalUrl: url, + isSponsored: keywords[0] == "sponsored", + }); + + // Block it. + if (UrlbarPrefs.get("resultMenu")) { + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 1, + }); + } else { + EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 }); + EventUtils.synthesizeKey("KEY_Enter"); + } + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + "Suggestion is blocked after picking block button" + ); + + // Make sure all previous suggestions remain blocked and no other + // suggestions are blocked yet. + for (let j = 0; j < REMOTE_SETTINGS_RESULTS.length; j++) { + Assert.equal( + await QuickSuggest.blockedSuggestions.has( + REMOTE_SETTINGS_RESULTS[j].url + ), + j <= i, + `Suggestion at index ${j} is blocked or not as expected` + ); + } + } + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggest.blockedSuggestions.clear(); + UrlbarPrefs.clear("bestMatch.enabled"); + } +}); + +// Tests with blocking disabled for both best matches and non-best-matches. +add_combo_task(async function disabled_both({ result, isBestMatch }) { + await doDisabledTest({ + result, + isBestMatch, + quickSuggestBlockingEnabled: false, + bestMatchBlockingEnabled: false, + }); +}); + +// Tests with blocking disabled only for non-best-matches. +add_combo_task(async function disabled_quickSuggest({ result, isBestMatch }) { + await doDisabledTest({ + result, + isBestMatch, + quickSuggestBlockingEnabled: false, + bestMatchBlockingEnabled: true, + }); +}); + +// Tests with blocking disabled only for best matches. +add_combo_task(async function disabled_bestMatch({ result, isBestMatch }) { + await doDisabledTest({ + result, + isBestMatch, + quickSuggestBlockingEnabled: true, + bestMatchBlockingEnabled: false, + }); +}); + +async function doDisabledTest({ + result, + isBestMatch, + bestMatchBlockingEnabled, + quickSuggestBlockingEnabled, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.bestMatch.blockingEnabled", bestMatchBlockingEnabled], + [ + "browser.urlbar.quicksuggest.blockingEnabled", + quickSuggestBlockingEnabled, + ], + ], + }); + + // Do a search to show a suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: result.keywords[0], + }); + let expectedResultCount = 2; + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResultCount, + "Two rows are present after searching (heuristic + suggestion)" + ); + let details = await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isBestMatch, + originalUrl: result.url, + isSponsored: result.keywords[0] == "sponsored", + }); + + // Arrow down to select the suggestion and press the key shortcut to block. + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + Assert.ok( + UrlbarTestUtils.isPopupOpen(window), + "View remains open after trying to block result" + ); + + if ( + (isBestMatch && !bestMatchBlockingEnabled) || + (!isBestMatch && !quickSuggestBlockingEnabled) + ) { + // Blocking is disabled. The key shortcut shouldn't have done anything. + if (!UrlbarPrefs.get("resultMenu")) { + Assert.ok( + !details.element.row._buttons.get("block"), + "Block button is not present" + ); + } + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResultCount, + "Same number of results after key shortcut" + ); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isBestMatch, + originalUrl: result.url, + isSponsored: result.keywords[0] == "sponsored", + }); + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(result.url)), + "Suggestion is not blocked" + ); + } else { + // Blocking is enabled. The suggestion should have been blocked. + if (!UrlbarPrefs.get("resultMenu")) { + Assert.ok( + details.element.row._buttons.get("block"), + "Block button is present" + ); + } + Assert.equal( + UrlbarTestUtils.getResultCount(window), + 1, + "Only one row after blocking suggestion" + ); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.url), + "Suggestion is blocked" + ); + await QuickSuggest.blockedSuggestions.clear(); + } + + await UrlbarTestUtils.promisePopupClose(window); + await SpecialPowers.popPrefEnv(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js new file mode 100644 index 0000000000..066ffecd51 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js @@ -0,0 +1,2101 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests QuickSuggest configurations. + */ + +ChromeUtils.defineESModuleGetters(this, { + EnterprisePolicyTesting: + "resource://testing-common/EnterprisePolicyTesting.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// We use this pref in enterprise preference policy tests. We specifically use a +// pref that's sticky and exposed in the UI to make sure it can be set properly. +const POLICY_PREF = "suggest.quicksuggest.nonsponsored"; + +let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); +let gUserBranch = Services.prefs.getBranch("browser.urlbar."); + +add_setup(async function () { + await QuickSuggestTestUtils.ensureQuickSuggestInit(); +}); + +// Makes sure `QuickSuggest._updateFeatureState()` is called when the +// `browser.urlbar.quicksuggest.enabled` pref is changed. +add_task(async function test_updateFeatureState_pref() { + Assert.ok( + UrlbarPrefs.get("quicksuggest.enabled"), + "Sanity check: quicksuggest.enabled is true by default" + ); + + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(QuickSuggest, "_updateFeatureState"); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.equal( + spy.callCount, + 1, + "_updateFeatureState called once after changing pref" + ); + + UrlbarPrefs.clear("quicksuggest.enabled"); + Assert.equal( + spy.callCount, + 2, + "_updateFeatureState called again after clearing pref" + ); + + sandbox.restore(); +}); + +// Makes sure `QuickSuggest._updateFeatureState()` is called when a Nimbus +// experiment is installed and uninstalled. +add_task(async function test_updateFeatureState_experiment() { + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(QuickSuggest, "_updateFeatureState"); + + await QuickSuggestTestUtils.withExperiment({ + callback: () => { + Assert.equal( + spy.callCount, + 1, + "_updateFeatureState called once after installing experiment" + ); + }, + }); + + Assert.equal( + spy.callCount, + 2, + "_updateFeatureState called again after uninstalling experiment" + ); + + sandbox.restore(); +}); + +add_task(async function test_indexes() { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestNonSponsoredIndex: 99, + quickSuggestSponsoredIndex: -1337, + }, + callback: () => { + Assert.equal( + UrlbarPrefs.get("quickSuggestNonSponsoredIndex"), + 99, + "quickSuggestNonSponsoredIndex" + ); + Assert.equal( + UrlbarPrefs.get("quickSuggestSponsoredIndex"), + -1337, + "quickSuggestSponsoredIndex" + ); + }, + }); +}); + +add_task(async function test_merino() { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + merinoEnabled: true, + merinoEndpointURL: "http://example.com/test_merino_config", + merinoClientVariants: "test-client-variants", + merinoProviders: "test-providers", + }, + callback: () => { + Assert.equal(UrlbarPrefs.get("merinoEnabled"), true, "merinoEnabled"); + Assert.equal( + UrlbarPrefs.get("merinoEndpointURL"), + "http://example.com/test_merino_config", + "merinoEndpointURL" + ); + Assert.equal( + UrlbarPrefs.get("merinoClientVariants"), + "test-client-variants", + "merinoClientVariants" + ); + Assert.equal( + UrlbarPrefs.get("merinoProviders"), + "test-providers", + "merinoProviders" + ); + }, + }); +}); + +add_task(async function test_scenario_online() { + await doBasicScenarioTest("online", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "online", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // Nimbus variables + quickSuggestScenario: "online", + quickSuggestEnabled: true, + quickSuggestShouldShowOnboardingDialog: true, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +}); + +add_task(async function test_scenario_offline() { + await doBasicScenarioTest("offline", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "offline", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // Nimbus variables + quickSuggestScenario: "offline", + quickSuggestEnabled: true, + quickSuggestShouldShowOnboardingDialog: false, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +}); + +add_task(async function test_scenario_history() { + await doBasicScenarioTest("history", { + urlbarPrefs: { + // prefs + "quicksuggest.scenario": "history", + "quicksuggest.enabled": false, + + // Nimbus variables + quickSuggestScenario: "history", + quickSuggestEnabled: false, + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: false, + }, + ], + }); +}); + +async function doBasicScenarioTest(scenario, expectedPrefs) { + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: scenario, + }, + callback: () => { + // Pref updates should always settle down by the time enrollment is done. + Assert.ok( + !UrlbarPrefs.updatingFirefoxSuggestPrefs, + "updatingFirefoxSuggestPrefs is false" + ); + + assertScenarioPrefs(expectedPrefs); + }, + }); + + // Similarly, pref updates should always settle down by the time unenrollment + // is done. + Assert.ok( + !UrlbarPrefs.updatingFirefoxSuggestPrefs, + "updatingFirefoxSuggestPrefs is false" + ); + + assertDefaultScenarioPrefs(); +} + +function assertScenarioPrefs({ urlbarPrefs, defaults }) { + for (let [name, value] of Object.entries(urlbarPrefs)) { + Assert.equal(UrlbarPrefs.get(name), value, `UrlbarPrefs.get("${name}")`); + } + + let prefs = Services.prefs.getDefaultBranch(""); + for (let { name, getter, value } of defaults) { + Assert.equal( + prefs[getter || "getBoolPref"](name), + value, + `Default branch pref: ${name}` + ); + } +} + +function assertDefaultScenarioPrefs() { + assertScenarioPrefs({ + urlbarPrefs: { + "quicksuggest.scenario": "offline", + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + + // No Nimbus variables since they're only available when an experiment is + // installed. + }, + defaults: [ + { + name: "browser.urlbar.quicksuggest.enabled", + value: true, + }, + { + name: "browser.urlbar.quicksuggest.dataCollection.enabled", + value: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + value: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + value: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + value: true, + }, + ], + }); +} + +function clearOnboardingPrefs() { + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts"); +} + +// The following tasks test Nimbus enrollments + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * History +// +// Expected: +// * All history prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "history", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + }); +}); + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * Offline +// +// Expected: +// * All offline prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + }); +}); + +// Initial state: +// * History (quick suggest feature disabled) +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// The following tasks test OFFLINE TO OFFLINE + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// +// Expected: +// * All offline prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test OFFLINE TO ONLINE + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test ONLINE TO ONLINE + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// +// Expected: +// * All online prefs set on the default branch +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain on +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user turned on +// +// Enrollment: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: remain off +// * Data collection: remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + }, + expectedPrefs: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test scenarios in conjunction with individual Nimbus +// variables + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// * Sponsored suggestions individually forced on +// +// Expected: +// * Sponsored suggestions: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Sponsored suggestions: user turned off +// +// Enrollment: +// * Offline +// * Sponsored suggestions individually forced on +// +// Expected: +// * Sponsored suggestions: remain off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Offline +// * Data collection individually forced on +// +// Expected: +// * Data collection: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Offline (suggestions on and data collection off by default) +// * Data collection: user turned off (it's off by default, so this simulates +// when the user toggled it on and then back off) +// +// Enrollment: +// * Offline +// * Data collection individually forced on +// +// Expected: +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "offline", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline, + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// * Sponsored suggestions individually forced off +// +// Expected: +// * Sponsored suggestions: off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Sponsored suggestions: user turned on (they're on by default, so this +// simulates when the user toggled them off and then back on) +// +// Enrollment: +// * Online +// * Sponsored suggestions individually forced off +// +// Expected: +// * Sponsored suggestions: remain on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * User did not override any defaults +// +// Enrollment: +// * Online +// * Data collection individually forced on +// +// Expected: +// * Data collection: on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Initial state: +// * Online (suggestions on and data collection off by default) +// * Data collection: user turned off (it's off by default, so this simulates +// when the user toggled it on and then back off) +// +// Enrollment: +// * Online +// * Data collection individually forced on +// +// Expected: +// * Data collection: remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online, + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// The following tasks test individual Nimbus variables without scenarios + +// Initial state: +// * Suggestions on by default and user left them on +// +// 1. First enrollment: +// * Suggestions forced off +// +// Expected: +// * Suggestions off +// +// 2. User turns on suggestions +// 3. Second enrollment: +// * Suggestions forced off again +// +// Expected: +// * Suggestions remain on +add_task(async function () { + await checkEnrollments([ + { + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }, + { + initialPrefsToSet: { + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }, + ]); +}); + +// Initial state: +// * Suggestions on by default but user turned them off +// +// Enrollment: +// * Suggestions forced on +// +// Expected: +// * Suggestions remain off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Initial state: +// * Suggestions off by default and user left them off +// +// 1. First enrollment: +// * Suggestions forced on +// +// Expected: +// * Suggestions on +// +// 2. User turns off suggestions +// 3. Second enrollment: +// * Suggestions forced on again +// +// Expected: +// * Suggestions remain off +add_task(async function () { + await checkEnrollments([ + { + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }, + { + initialPrefsToSet: { + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: true, + quickSuggestSponsoredEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }, + ]); +}); + +// Initial state: +// * Suggestions off by default but user turned them on +// +// Enrollment: +// * Suggestions forced off +// +// Expected: +// * Suggestions remain on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + valueOverrides: { + quickSuggestNonSponsoredEnabled: false, + quickSuggestSponsoredEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Initial state: +// * Data collection on by default and user left them on +// +// 1. First enrollment: +// * Data collection forced off +// +// Expected: +// * Data collection off +// +// 2. User turns on data collection +// 3. Second enrollment: +// * Data collection forced off again +// +// Expected: +// * Data collection remains on +add_task(async function () { + await checkEnrollments( + [ + { + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }, + ], + [ + { + initialPrefsToSet: { + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }, + ] + ); +}); + +// Initial state: +// * Data collection on by default but user turned it off +// +// Enrollment: +// * Data collection forced on +// +// Expected: +// * Data collection remains off +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Initial state: +// * Data collection off by default and user left it off +// +// 1. First enrollment: +// * Data collection forced on +// +// Expected: +// * Data collection on +// +// 2. User turns off data collection +// 3. Second enrollment: +// * Data collection forced on again +// +// Expected: +// * Data collection remains off +add_task(async function () { + await checkEnrollments( + [ + { + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }, + ], + [ + { + initialPrefsToSet: { + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: true, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + }, + }, + ] + ); +}); + +// Initial state: +// * Data collection off by default but user turned it on +// +// Enrollment: +// * Data collection forced off +// +// Expected: +// * Data collection remains on +add_task(async function () { + await checkEnrollments({ + initialPrefsToSet: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + valueOverrides: { + quickSuggestDataCollectionEnabled: false, + }, + expectedPrefs: { + defaultBranch: { + "quicksuggest.dataCollection.enabled": false, + }, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +/** + * Tests one or more enrollments. Sets an initial set of prefs on the default + * and/or user branches, enrolls in a mock Nimbus experiment, checks expected + * pref values, unenrolls, and finally checks prefs again. + * + * The given `options` value may be an object as described below or an array of + * such objects, one per enrollment. + * + * @param {object} options + * Function options. + * @param {object} options.initialPrefsToSet + * An object: { userBranch, defaultBranch } + * `userBranch` and `defaultBranch` are objects that map pref names (relative + * to `browser.urlbar`) to values. These prefs will be set on the appropriate + * branch before enrollment. Both `userBranch` and `defaultBranch` are + * optional. + * @param {object} options.valueOverrides + * The `valueOverrides` object passed to the mock experiment. It should map + * Nimbus variable names to values. + * @param {object} options.expectedPrefs + * Preferences that should be set after enrollment. It has the same shape as + * `options.initialPrefsToSet`. + */ +async function checkEnrollments(options) { + info("Testing: " + JSON.stringify(options)); + + let enrollments; + if (Array.isArray(options)) { + enrollments = options; + } else { + enrollments = [options]; + } + + // Do each enrollment. + for (let i = 0; i < enrollments.length; i++) { + info( + `Starting setup for enrollment ${i}: ` + JSON.stringify(enrollments[i]) + ); + + let { initialPrefsToSet, valueOverrides, expectedPrefs } = enrollments[i]; + + // Set initial prefs. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + let { defaultBranch: initialDefaultBranch, userBranch: initialUserBranch } = + initialPrefsToSet; + initialDefaultBranch = initialDefaultBranch || {}; + initialUserBranch = initialUserBranch || {}; + for (let name of Object.keys(initialDefaultBranch)) { + // Clear user-branch values on the default prefs so the defaults aren't + // masked. + gUserBranch.clearUserPref(name); + } + for (let [branch, prefs] of [ + [gDefaultBranch, initialDefaultBranch], + [gUserBranch, initialUserBranch], + ]) { + for (let [name, value] of Object.entries(prefs)) { + branch.setBoolPref(name, value); + } + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + + let { + defaultBranch: expectedDefaultBranch, + userBranch: expectedUserBranch, + } = expectedPrefs; + expectedDefaultBranch = expectedDefaultBranch || {}; + expectedUserBranch = expectedUserBranch || {}; + + // Install the experiment. + info(`Installing experiment for enrollment ${i}`); + await QuickSuggestTestUtils.withExperiment({ + valueOverrides, + callback: () => { + info(`Installed experiment for enrollment ${i}, now checking prefs`); + + // Check expected pref values. Store expected effective values as we go + // so we can check them afterward. For a given pref, the expected + // effective value is the user value, or if there's not a user value, + // the default value. + let expectedEffectivePrefs = {}; + for (let [branch, prefs, branchType] of [ + [gDefaultBranch, expectedDefaultBranch, "default"], + [gUserBranch, expectedUserBranch, "user"], + ]) { + for (let [name, value] of Object.entries(prefs)) { + expectedEffectivePrefs[name] = value; + Assert.equal( + branch.getBoolPref(name), + value, + `Pref ${name} on ${branchType} branch` + ); + if (branch == gUserBranch) { + Assert.ok( + gUserBranch.prefHasUserValue(name), + `Pref ${name} is on user branch` + ); + } + } + } + for (let name of Object.keys(initialDefaultBranch)) { + if (!expectedUserBranch.hasOwnProperty(name)) { + Assert.ok( + !gUserBranch.prefHasUserValue(name), + `Pref ${name} is not on user branch` + ); + } + } + for (let [name, value] of Object.entries(expectedEffectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value` + ); + } + + info(`Uninstalling experiment for enrollment ${i}`); + }, + }); + + info(`Uninstalled experiment for enrollment ${i}, now checking prefs`); + + // Check expected effective values after unenrollment. The expected + // effective value for a pref at this point is the value on the user branch, + // or if there's not a user value, the original value on the default branch + // before enrollment. This assumes the default values reflect the offline + // scenario (the case for the U.S. region). + let effectivePrefs = Object.assign( + {}, + UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline + ); + for (let [name, value] of Object.entries(expectedUserBranch)) { + effectivePrefs[name] = value; + } + for (let [name, value] of Object.entries(effectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value after unenrolling` + ); + } + + // Clean up. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + for (let name of Object.keys(expectedUserBranch)) { + UrlbarPrefs.clear(name); + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + } +} + +// The following tasks test enterprise preference policies + +// Preference policy test for the following: +// * Status: locked +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "locked", + Value: false, + }, + expectedDefault: false, + expectedUser: undefined, + expectedLocked: true, + }); +}); + +// Preference policy test for the following: +// * Status: locked +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "locked", + Value: true, + }, + expectedDefault: true, + expectedUser: undefined, + expectedLocked: true, + }); +}); + +// Preference policy test for the following: +// * Status: default +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "default", + Value: false, + }, + expectedDefault: false, + expectedUser: undefined, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: default +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "default", + Value: true, + }, + expectedDefault: true, + expectedUser: undefined, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: user +// * Value: false +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "user", + Value: false, + }, + expectedDefault: true, + expectedUser: false, + expectedLocked: false, + }); +}); + +// Preference policy test for the following: +// * Status: user +// * Value: true +add_task(async function () { + await doPolicyTest({ + prefPolicy: { + Status: "user", + Value: true, + }, + expectedDefault: true, + // Because the pref is sticky, it's true on the user branch even though it's + // also true on the default branch. Sticky prefs retain their user-branch + // values even when they're the same as their default-branch values. + expectedUser: true, + expectedLocked: false, + }); +}); + +/** + * This tests an enterprise preference policy with one of the quick suggest + * sticky prefs (defined by `POLICY_PREF`). Pref policies should apply to the + * quick suggest sticky prefs just as they do to non-sticky prefs. + * + * @param {object} options + * Options object. + * @param {object} options.prefPolicy + * An object `{ Status, Value }` that will be included in the policy. + * @param {boolean} options.expectedDefault + * The expected default-branch pref value after setting the policy. + * @param {boolean} options.expectedUser + * The expected user-branch pref value after setting the policy or undefined + * if the pref should not exist on the user branch. + * @param {boolean} options.expectedLocked + * Whether the pref is expected to be locked after setting the policy. + */ +async function doPolicyTest({ + prefPolicy, + expectedDefault, + expectedUser, + expectedLocked, +}) { + info( + "Starting pref policy test: " + + JSON.stringify({ + prefPolicy, + expectedDefault, + expectedUser, + expectedLocked, + }) + ); + + let pref = POLICY_PREF; + + // Check initial state. + Assert.ok( + gDefaultBranch.getBoolPref(pref), + `${pref} is initially true on default branch (assuming en-US)` + ); + Assert.ok( + !gUserBranch.prefHasUserValue(pref), + `${pref} does not have initial user value` + ); + + // Set up the policy. + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + Preferences: { + [`browser.urlbar.${pref}`]: prefPolicy, + }, + }, + }); + Assert.equal( + Services.policies.status, + Ci.nsIEnterprisePolicies.ACTIVE, + "Policy engine is active" + ); + + // Check the default branch. + Assert.equal( + gDefaultBranch.getBoolPref(pref), + expectedDefault, + `${pref} has expected default-branch value after setting policy` + ); + + // Check the user branch. + Assert.equal( + gUserBranch.prefHasUserValue(pref), + expectedUser !== undefined, + `${pref} is on user branch as expected after setting policy` + ); + if (expectedUser !== undefined) { + Assert.equal( + gUserBranch.getBoolPref(pref), + expectedUser, + `${pref} has expected user-branch value after setting policy` + ); + } + + // Check the locked state. + Assert.equal( + gDefaultBranch.prefIsLocked(pref), + expectedLocked, + `${pref} is locked as expected after setting policy` + ); + + // Clean up. + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + Assert.equal( + Services.policies.status, + Ci.nsIEnterprisePolicies.INACTIVE, + "Policy engine is inactive" + ); + + gDefaultBranch.unlockPref(pref); + gUserBranch.clearUserPref(pref); + await QuickSuggestTestUtils.setScenario(null); + + Assert.ok( + !gDefaultBranch.prefIsLocked(pref), + `${pref} is not locked after cleanup` + ); + Assert.ok( + gDefaultBranch.getBoolPref(pref), + `${pref} is true on default branch after cleanup (assuming en-US)` + ); + Assert.ok( + !gUserBranch.prefHasUserValue(pref), + `${pref} does not have user value after cleanup` + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js new file mode 100644 index 0000000000..282e1a2ba0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js @@ -0,0 +1,425 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the configurable indexes of sponsored and non-sponsored ("Firefox +// Suggest") quick suggest results. + +"use strict"; + +const SUGGESTIONS_FIRST_PREF = "browser.urlbar.showSearchSuggestionsFirst"; +const SUGGESTIONS_PREF = "browser.urlbar.suggest.searches"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); + +const SPONSORED_INDEX_PREF = "browser.urlbar.quicksuggest.sponsoredIndex"; +const NON_SPONSORED_INDEX_PREF = + "browser.urlbar.quicksuggest.nonSponsoredIndex"; + +const SPONSORED_SEARCH_STRING = "frabbits"; +const NON_SPONSORED_SEARCH_STRING = "nonspon"; + +const TEST_URL = "http://example.com/quicksuggest"; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: `${TEST_URL}?q=${SPONSORED_SEARCH_STRING}`, + title: "frabbits", + keywords: [SPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + }, + { + id: 2, + url: `${TEST_URL}?q=${NON_SPONSORED_SEARCH_STRING}`, + title: "Non-Sponsored", + keywords: [NON_SPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + }, +]; + +add_setup(async function () { + // This test intermittently times out on Mac TV WebRender. + if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); + } + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +// Tests with history only +add_task(async function noSuggestions() { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 2, + expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 1, + })); +}); + +// Tests with suggestions followed by history +add_task(async function suggestionsFirst() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, true]], + }); + await withSuggestions(async () => { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 4, + expectedIndex: generalIndex == 0 || !withHistory ? 3 : MAX_RESULTS - 1, + })); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history followed by suggestions +add_task(async function suggestionsLast() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await withSuggestions(async () => { + await doTestPermutations(({ withHistory, generalIndex }) => ({ + expectedResultCount: withHistory ? MAX_RESULTS : 4, + expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 3, + })); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history only plus a suggestedIndex result with a resultSpan +add_task(async function otherSuggestedIndex_noSuggestions() { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + ]); +}); + +// Tests with suggestions followed by history plus a suggestedIndex result with +// a resultSpan +add_task(async function otherSuggestedIndex_suggestionsFirst() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, true]], + }); + await withSuggestions(async () => { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // search suggestions + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" }, + }, + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" }, + }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + ]); + }); + await SpecialPowers.popPrefEnv(); +}); + +// Tests with history followed by suggestions plus a suggestedIndex result with +// a resultSpan +add_task(async function otherSuggestedIndex_suggestionsLast() { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_FIRST_PREF, false]], + }); + await withSuggestions(async () => { + await doSuggestedIndexTest([ + // heuristic + { heuristic: true }, + // TestProvider result + { suggestedIndex: 1, resultSpan: 2 }, + // history + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + { type: UrlbarUtils.RESULT_TYPE.URL }, + // quick suggest + { + type: UrlbarUtils.RESULT_TYPE.URL, + providerName: UrlbarProviderQuickSuggest.name, + }, + // search suggestions + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" }, + }, + { + type: UrlbarUtils.RESULT_TYPE.SEARCH, + payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" }, + }, + ]); + }); + await SpecialPowers.popPrefEnv(); +}); + +/** + * A test provider that returns one result with a suggestedIndex and resultSpan. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + constructor() { + super({ + results: [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { url: "http://example.com/test" } + ), + { + suggestedIndex: 1, + resultSpan: 2, + } + ), + ], + }); + } +} + +/** + * Does a round of test permutations. + * + * @param {Function} callback + * For each permutation, this will be called with the arguments of `doTest()`, + * and it should return an object with the appropriate values of + * `expectedResultCount` and `expectedIndex`. + */ +async function doTestPermutations(callback) { + for (let isSponsored of [true, false]) { + for (let withHistory of [true, false]) { + for (let generalIndex of [0, -1]) { + let opts = { + isSponsored, + withHistory, + generalIndex, + }; + await doTest(Object.assign(opts, callback(opts))); + } + } + } +} + +/** + * Does one test run. + * + * @param {object} options + * Options for the test. + * @param {boolean} options.isSponsored + * True to use a sponsored result, false to use a non-sponsored result. + * @param {boolean} options.withHistory + * True to run with a bunch of history, false to run with no history. + * @param {number} options.generalIndex + * The value to set as the relevant index pref, i.e., the index within the + * general group of the quick suggest result. + * @param {number} options.expectedResultCount + * The expected total result count for sanity checking. + * @param {number} options.expectedIndex + * The expected index of the quick suggest result in the whole results list. + */ +async function doTest({ + isSponsored, + withHistory, + generalIndex, + expectedResultCount, + expectedIndex, +}) { + info( + "Running test with options: " + + JSON.stringify({ + isSponsored, + withHistory, + generalIndex, + expectedResultCount, + expectedIndex, + }) + ); + + // Set the index pref. + let indexPref = isSponsored ? SPONSORED_INDEX_PREF : NON_SPONSORED_INDEX_PREF; + await SpecialPowers.pushPrefEnv({ + set: [[indexPref, generalIndex]], + }); + + // Add history. + if (withHistory) { + await addHistory(); + } + + // Do a search. + let value = isSponsored + ? SPONSORED_SEARCH_STRING + : NON_SPONSORED_SEARCH_STRING; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value, + }); + + // Check the result count and quick suggest result. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResultCount, + "Expected result count" + ); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + isSponsored, + index: expectedIndex, + url: isSponsored + ? `${TEST_URL}?q=${SPONSORED_SEARCH_STRING}` + : `${TEST_URL}?q=${NON_SPONSORED_SEARCH_STRING}`, + }); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); + await SpecialPowers.popPrefEnv(); +} + +/** + * Adds history that matches the sponsored and non-sponsored search strings. + */ +async function addHistory() { + for (let i = 0; i < MAX_RESULTS; i++) { + await PlacesTestUtils.addVisits([ + "http://example.com/" + SPONSORED_SEARCH_STRING + i, + "http://example.com/" + NON_SPONSORED_SEARCH_STRING + i, + ]); + } +} + +/** + * Adds a search engine that provides suggestions, calls your callback, and then + * removes the engine. + * + * @param {Function} callback + * Your callback function. + */ +async function withSuggestions(callback) { + await SpecialPowers.pushPrefEnv({ + set: [[SUGGESTIONS_PREF, true]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + try { + await callback(engine); + } finally { + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(engine); + await SpecialPowers.popPrefEnv(); + } +} + +/** + * Registers a test provider that returns a result with a suggestedIndex and + * resultSpan and asserts the given expected results match the actual results. + * + * @param {Array} expectedProps + * See `checkResults()`. + */ +async function doSuggestedIndexTest(expectedProps) { + await addHistory(); + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + + let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: SPONSORED_SEARCH_STRING, + }); + checkResults(context.results, expectedProps); + await UrlbarTestUtils.promisePopupClose(window); + + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); +} + +/** + * Asserts the given actual and expected results match. + * + * @param {Array} actualResults + * Array of actual results. + * @param {Array} expectedProps + * Array of expected result-like objects. Only the properties defined in each + * of these objects are compared against the corresponding actual result. + */ +function checkResults(actualResults, expectedProps) { + Assert.equal( + actualResults.length, + expectedProps.length, + "Expected result count" + ); + + let actualProps = actualResults.map((actual, i) => { + if (expectedProps.length <= i) { + return actual; + } + let props = {}; + let expected = expectedProps[i]; + for (let [key, expectedValue] of Object.entries(expected)) { + if (key != "payload") { + props[key] = actual[key]; + } else { + props.payload = {}; + for (let pkey of Object.keys(expectedValue)) { + props.payload[pkey] = actual.payload[pkey]; + } + } + } + return props; + }); + Assert.deepEqual(actualProps, expectedProps); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js new file mode 100644 index 0000000000..050ea31e12 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// End-to-end browser smoke test for Merino sessions. More comprehensive tests +// are in test_quicksuggest_merinoSessions.js. This test essentially makes sure +// engagements occur as expected when interacting with the urlbar. If you need +// to add tests that do not depend on a new definition of "engagement", consider +// adding them to test_quicksuggest_merinoSessions.js instead. + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.merino.enabled", true], + ["browser.urlbar.quicksuggest.remoteSettings.enabled", false], + ["browser.urlbar.quicksuggest.dataCollection.enabled", true], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + // Install a mock default engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await MerinoTestUtils.server.start(); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleEngagement() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. This task closes the panel between +// searches but keeps the input focused, so the engagement should not end. +add_task(async function singleEngagement_panelClosed() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + + EventUtils.synthesizeKey("KEY_Escape"); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Panel is closed"); + Assert.ok(gURLBar.focused, "Input remains focused"); + } + + // End the engagement to reset the session for the next test. + gURLBar.blur(); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task completes each engagement +// successfully. +add_task(async function manyEngagements_engagement() { + for (let i = 0; i < 3; i++) { + // Open a new tab since we'll load the mock default search engine page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + // Press enter on the heuristic result to load the search engine page and + // complete the engagement. + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + } +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task abandons each engagement. +add_task(async function manyEngagements_abandonment() { + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + // Blur the urlbar to abandon the engagement. + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js new file mode 100644 index 0000000000..92c3c7c95d --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js @@ -0,0 +1,1596 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests the buttons in the onboarding dialog for quick suggest/Firefox Suggest. + */ + +ChromeUtils.defineESModuleGetters(this, { + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", +}); + +const OTHER_DIALOG_URI = getRootDirectory(gTestPath) + "subdialog.xhtml"; + +// Default-branch pref values in the offline scenario. +const OFFLINE_DEFAULT_PREFS = { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, +}; + +let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); +let gUserBranch = Services.prefs.getBranch("browser.urlbar."); + +// Allow more time for Mac and Linux machines so they don't time out in verify mode. +if (AppConstants.platform === "macosx") { + requestLongerTimeout(4); +} else if (AppConstants.platform === "linux") { + requestLongerTimeout(2); +} + +// Whether the tab key can move the focus. On macOS with full keyboard access +// disabled (which is default), this will be false. See `canTabMoveFocus`. +let gCanTabMoveFocus; +add_setup(async function () { + gCanTabMoveFocus = await canTabMoveFocus(); +}); + +// When the user has already enabled the data-collection pref, the dialog should +// not appear. +add_task(async function dataCollectionAlreadyEnabled() { + setDialogPrereqPrefs(); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + + info("Calling maybeShowOnboardingDialog"); + let showed = await QuickSuggest.maybeShowOnboardingDialog(); + Assert.ok(!showed, "The dialog was not shown"); + + UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); +}); + +// When the current tab is about:welcome, the dialog should not appear. +add_task(async function aboutWelcome() { + setDialogPrereqPrefs(); + await BrowserTestUtils.withNewTab("about:welcome", async () => { + info("Calling maybeShowOnboardingDialog"); + let showed = await QuickSuggest.maybeShowOnboardingDialog(); + Assert.ok(!showed, "The dialog was not shown"); + }); +}); + +// The Escape key should dismiss the dialog without opting in. This task tests +// when Escape is pressed while the focus is inside the dialog. +add_task(async function escKey_focusInsideDialog() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + const tabCount = gBrowser.tabs.length; + Assert.ok( + document.activeElement.classList.contains("dialogFrame"), + "dialogFrame is focused in the browser window" + ); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape"); + + await maybeShowPromise; + + Assert.equal( + gBrowser.currentURI.spec, + "about:blank", + "Nothing loaded in the current tab" + ); + Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened"); + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +}); + +// The Escape key should dismiss the dialog without opting in. This task tests +// when Escape is pressed while the focus is outside the dialog. +add_task(async function escKey_focusOutsideDialog() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + document.documentElement.focus(); + Assert.ok( + !document.activeElement.classList.contains("dialogFrame"), + "dialogFrame is not focused in the browser window" + ); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape"); + + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +}); + +// The Escape key should dismiss the dialog without opting in when another +// dialog is queued and shown before the onboarding. This task dismisses the +// other dialog by pressing the Escape key. +add_task(async function escKey_queued_esc() { + await doQueuedEscKeyTest("KEY_Escape"); +}); + +// The Escape key should dismiss the dialog without opting in when another +// dialog is queued and shown before the onboarding. This task dismisses the +// other dialog by pressing the Enter key. +add_task(async function escKey_queued_enter() { + await doQueuedEscKeyTest("KEY_Enter"); +}); + +async function doQueuedEscKeyTest(otherDialogKey) { + await doDialogTest({ + callback: async () => { + // Create promises that will resolve when each dialog is opened. + let uris = [OTHER_DIALOG_URI, QuickSuggest.ONBOARDING_URI]; + let [otherOpenedPromise, onboardingOpenedPromise] = uris.map(uri => + TestUtils.topicObserved( + "subdialog-loaded", + contentWin => contentWin.document.documentURI == uri + ).then(async ([contentWin]) => { + if (contentWin.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(contentWin, "load"); + } + }) + ); + + info("Queuing dialogs for opening"); + let otherClosedPromise = gDialogBox.open(OTHER_DIALOG_URI); + let onboardingClosedPromise = QuickSuggest.maybeShowOnboardingDialog(); + + info("Waiting for the other dialog to open"); + await otherOpenedPromise; + + info(`Pressing ${otherDialogKey} and waiting for other dialog to close`); + EventUtils.synthesizeKey(otherDialogKey); + await otherClosedPromise; + + info("Waiting for the onboarding dialog to open"); + await onboardingOpenedPromise; + + info("Pressing Escape and waiting for onboarding dialog to close"); + EventUtils.synthesizeKey("KEY_Escape"); + await onboardingClosedPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_1", + }, + ], + }); +} + +// Tests `dismissed_other` by closing the dialog programmatically. +add_task(async function dismissed_other_on_introduction() { + await doDialogTest({ + callback: async () => { + const { maybeShowPromise } = await showOnboardingDialog(); + gDialogBox._dialog.close(); + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_1", + }, + ], + }); +}); + +// The default is to wait for no browser restarts to show the onboarding dialog +// on the first restart. This tests that we can override it by configuring the +// `showOnboardingDialogOnNthRestart` +add_task(async function nimbus_override_wait_after_n_restarts() { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + // Wait for 1 browser restart + quickSuggestShowOnboardingDialogAfterNRestarts: 1, + }, + callback: async () => { + let prefPromise = TestUtils.waitForPrefChange( + "browser.urlbar.quicksuggest.showedOnboardingDialog", + value => value === true + ).then(() => info("Saw pref change")); + + // Simulate 2 restarts. this function is only called by BrowserGlue + // on startup, the first restart would be where MR1 was shown then + // we will show onboarding the 2nd restart after that. + info("Simulating first restart"); + await QuickSuggest.maybeShowOnboardingDialog(); + + info("Simulating second restart"); + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + QuickSuggest.ONBOARDING_URI, + { isSubDialog: true } + ); + const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog(); + const win = await dialogPromise; + if (win.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(win, "load"); + } + // Close dialog. + EventUtils.synthesizeKey("KEY_Escape"); + + info("Waiting for maybeShowPromise and pref change"); + await Promise.all([maybeShowPromise, prefPromise]); + }, + }); +}); + +add_task(async function nimbus_skip_onboarding_dialog() { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestShouldShowOnboardingDialog: false, + }, + callback: async () => { + // Simulate 3 restarts. + for (let i = 0; i < 3; i++) { + info(`Simulating restart ${i + 1}`); + await QuickSuggest.maybeShowOnboardingDialog(); + } + Assert.ok( + !Services.prefs.getBoolPref( + "browser.urlbar.quicksuggest.showedOnboardingDialog", + false + ), + "The showed onboarding dialog pref should not be set" + ); + }, + }); +}); + +add_task(async function nimbus_exposure_event() { + const testData = [ + { + experimentType: "modal", + expectedRecorded: true, + }, + { + experimentType: "best-match", + expectedRecorded: false, + }, + { + expectedRecorded: false, + }, + ]; + + for (const { experimentType, expectedRecorded } of testData) { + info(`Nimbus exposure event test for type:[${experimentType}]`); + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.clearExposureEvent(); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + experimentType, + }, + callback: async () => { + info("Calling showOnboardingDialog"); + const { maybeShowPromise } = await showOnboardingDialog(); + EventUtils.synthesizeKey("KEY_Escape"); + await maybeShowPromise; + + info("Check the event"); + await QuickSuggestTestUtils.assertExposureEvent(expectedRecorded); + }, + }); + } +}); + +const LOGO_TYPE = { + FIREFOX: 1, + MAGGLASS: 2, + ANIMATION_MAGGLASS: 3, +}; + +const VARIATION_TEST_DATA = [ + { + name: "A", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-1", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingClose", + "onboardingNext", + ], + actions: ["onboardingClose", "onboardingNext"], + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-1", + "main-description": "firefox-suggest-onboarding-main-description-1", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingAccept", + "onboardingLearnMore", + "onboardingReject", + "onboardingSkipLink", + "onboardingAccept", + ], + acceptFocusOrder: [ + "onboardingAccept", + "onboardingLearnMore", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingAccept", + ], + rejectFocusOrder: [ + "onboardingReject", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingLearnMore", + "onboardingReject", + ], + actions: [ + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + }, + }, + { + // We don't need to test the focus order and actions because the layout of + // variation B-H is as same as A. + name: "B", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-2", + "main-description": "firefox-suggest-onboarding-main-description-2", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "C", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-3", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-3", + "main-description": "firefox-suggest-onboarding-main-description-3", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "D", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-4", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-4", + "main-description": "firefox-suggest-onboarding-main-description-4", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "E", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-5", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-5", + "main-description": "firefox-suggest-onboarding-main-description-5", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "F", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-2", + "introduction-title": "firefox-suggest-onboarding-introduction-title-6", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-6", + "main-description": "firefox-suggest-onboarding-main-description-6", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "G", + introductionSection: { + logoType: LOGO_TYPE.ANIMATION_MAGGLASS, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-7", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.MAGGLASS, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-7", + "main-description": "firefox-suggest-onboarding-main-description-7", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-2", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-2", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "H", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1", + "introduction-title": "firefox-suggest-onboarding-introduction-title-2", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": false, + ".description-section": false, + ".pager": true, + }, + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-8", + "main-description": "firefox-suggest-onboarding-main-description-8", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-1", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-1", + }, + visibility: { + "#main-privacy-first": false, + ".description-section #onboardingLearnMore": false, + ".accept #onboardingLearnMore": true, + ".pager": true, + }, + }, + }, + { + name: "100-A", + introductionSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + onboardingNext: "firefox-suggest-onboarding-introduction-next-button-3", + "introduction-title": "firefox-suggest-onboarding-main-title-9", + }, + visibility: { + "#onboardingLearnMoreOnIntroduction": true, + ".description-section": true, + ".pager": true, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingLearnMoreOnIntroduction", + "onboardingClose", + "onboardingNext", + ], + actions: [ + "onboardingClose", + "onboardingNext", + "onboardingLearnMoreOnIntroduction", + ], + }, + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": true, + ".accept #onboardingLearnMore": false, + ".pager": false, + }, + defaultFocusOrder: [ + "onboardingNext", + "onboardingLearnMore", + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + acceptFocusOrder: [ + "onboardingAccept", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingLearnMore", + "onboardingAccept", + ], + rejectFocusOrder: [ + "onboardingReject", + "onboardingSubmit", + "onboardingSkipLink", + "onboardingLearnMore", + "onboardingReject", + ], + actions: [ + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + }, + }, + { + name: "100-B", + mainSection: { + logoType: LOGO_TYPE.FIREFOX, + l10n: { + "main-title": "firefox-suggest-onboarding-main-title-9", + "main-description": "firefox-suggest-onboarding-main-description-9", + "main-accept-option-label": + "firefox-suggest-onboarding-main-accept-option-label-2", + "main-accept-option-description": + "firefox-suggest-onboarding-main-accept-option-description-3", + "main-reject-option-label": + "firefox-suggest-onboarding-main-reject-option-label-2", + "main-reject-option-description": + "firefox-suggest-onboarding-main-reject-option-description-3", + }, + visibility: { + "#main-privacy-first": true, + ".description-section #onboardingLearnMore": true, + ".accept #onboardingLearnMore": false, + ".pager": false, + }, + // Layout of 100-B is same as 100-A, but since there is no the introduction + // pane, only the default focus order on the main pane is a bit diffrence. + defaultFocusOrder: [ + "onboardingLearnMore", + "onboardingAccept", + "onboardingReject", + "onboardingSkipLink", + "onboardingLearnMore", + ], + }, + }, +]; + +/** + * This test checks for differences due to variations in logo type, l10n text, + * element visibility, order of focus, actions, etc. The designation is on + * VARIATION_TEST_DATA. The items that can be specified are below. + * + * name: Specify the variation name. + * + * The following items are specified for each section. + * (introductionSection, mainSection). + * + * logoType: + * Specify the expected logo type. Please refer to LOGO_TYPE about the type. + * + * l10n: + * Specify the expected l10n id applied to elements. + * + * visibility: + * Specify the expected visibility of elements. The way to specify the element + * is using selector. + * + * defaultFocusOrder: + * Specify the expected focus order right after the section is appeared. The + * way to specify the element is using id. + * + * acceptFocusOrder: + * Specify the expected focus order after selecting accept option. + * + * rejectFocusOrder: + * Specify the expected focus order after selecting reject option. + * + * actions: + * Specify the action we want to verify such as clicking the close button. The + * available actions are below. + * - onboardingClose: + * Action of the close button “x” by mouse/keyboard. + * - onboardingNext: + * Action of the next button that transits from the introduction section to + * the main section by mouse/keyboard. + * - onboardingAccept: + * Action of the submit button by mouse/keyboard after selecting accept + * option by mouse/keyboard. + * - onboardingReject: + * Action of the submit button by mouse/keyboard after selecting reject + * option by mouse/keyboard. + * - onboardingSkipLink: + * Action of the skip link by mouse/keyboard. + * - onboardingLearnMore: + * Action of the learn more link by mouse/keyboard. + * - onboardingLearnMoreOnIntroduction: + * Action of the learn more link on the introduction section by + * mouse/keyboard. + */ +add_task(async function variation_test() { + for (const variation of VARIATION_TEST_DATA) { + info(`Test for variation [${variation.name}]`); + + info("Do layout test"); + await doLayoutTest(variation); + + for (const action of variation.introductionSection?.actions || []) { + info( + `${action} test on the introduction section for variation [${variation.name}]` + ); + await this[action](variation); + } + + for (const action of variation.mainSection?.actions || []) { + info( + `${action} test on the main section for variation [${variation.name}]` + ); + await this[action](variation, !!variation.introductionSection); + } + } +}); + +async function doLayoutTest(variation) { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestOnboardingDialogVariation: variation.name, + }, + callback: async () => { + info("Calling showOnboardingDialog"); + const { win, maybeShowPromise } = await showOnboardingDialog(); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + + if (variation.introductionSection) { + info("Check the section visibility"); + Assert.ok(BrowserTestUtils.is_visible(introductionSection)); + Assert.ok(BrowserTestUtils.is_hidden(mainSection)); + + info("Check the introduction section"); + await assertSection(introductionSection, variation.introductionSection); + + info("Transition to the main section"); + win.document.getElementById("onboardingNext").click(); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.is_hidden(introductionSection) && + BrowserTestUtils.is_visible(mainSection) + ); + } else { + info("Check the section visibility"); + Assert.ok(BrowserTestUtils.is_hidden(introductionSection)); + Assert.ok(BrowserTestUtils.is_visible(mainSection)); + } + + info("Check the main section"); + await assertSection(mainSection, variation.mainSection); + + info("Close the dialog"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + await maybeShowPromise; + }, + }); +} + +async function assertSection(sectionElement, expectedSection) { + info("Check the logo"); + assertLogo(sectionElement, expectedSection.logoType); + + info("Check the l10n"); + assertL10N(sectionElement, expectedSection.l10n); + + info("Check the visibility"); + assertVisibility(sectionElement, expectedSection.visibility); + + if (!gCanTabMoveFocus) { + Assert.ok(true, "Tab key can't move focus, skipping test for focus order"); + return; + } + + if (expectedSection.defaultFocusOrder) { + info("Check the default focus order"); + assertFocusOrder(sectionElement, expectedSection.defaultFocusOrder); + } + + if (expectedSection.acceptFocusOrder) { + info("Check the focus order after selecting accept option"); + sectionElement.querySelector("#onboardingAccept").focus(); + EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal); + assertFocusOrder(sectionElement, expectedSection.acceptFocusOrder); + } + + if (expectedSection.rejectFocusOrder) { + info("Check the focus order after selecting reject option"); + sectionElement.querySelector("#onboardingReject").focus(); + EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal); + assertFocusOrder(sectionElement, expectedSection.rejectFocusOrder); + } +} + +function assertLogo(sectionElement, expectedLogoType) { + let expectedLogoImage; + switch (expectedLogoType) { + case LOGO_TYPE.FIREFOX: { + expectedLogoImage = 'url("chrome://branding/content/about-logo.svg")'; + break; + } + case LOGO_TYPE.MAGGLASS: { + expectedLogoImage = + 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")'; + break; + } + case LOGO_TYPE.ANIMATION_MAGGLASS: { + const mediaQuery = sectionElement.ownerGlobal.matchMedia( + "(prefers-reduced-motion: no-preference)" + ); + expectedLogoImage = mediaQuery.matches + ? 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass_animation.svg")' + : 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")'; + break; + } + default: { + Assert.ok(false, `Unexpected image type ${expectedLogoType}`); + break; + } + } + + const logo = sectionElement.querySelector(".logo"); + Assert.ok(BrowserTestUtils.is_visible(logo)); + const logoImage = + sectionElement.ownerGlobal.getComputedStyle(logo).backgroundImage; + Assert.equal(logoImage, expectedLogoImage); +} + +function assertL10N(sectionElement, expectedL10N) { + for (const [id, l10n] of Object.entries(expectedL10N)) { + const element = sectionElement.querySelector("#" + id); + Assert.equal(element.getAttribute("data-l10n-id"), l10n); + } +} + +function assertVisibility(sectionElement, expectedVisibility) { + for (const [selector, visibility] of Object.entries(expectedVisibility)) { + const element = sectionElement.querySelector(selector); + if (visibility) { + Assert.ok(BrowserTestUtils.is_visible(element)); + } else { + if (!element) { + Assert.ok(true); + return; + } + Assert.ok(BrowserTestUtils.is_hidden(element)); + } + } +} + +function assertFocusOrder(sectionElement, expectedFocusOrder) { + const win = sectionElement.ownerGlobal; + + // Check initial active element. + Assert.equal(win.document.activeElement.id, expectedFocusOrder[0]); + + for (const next of expectedFocusOrder.slice(1)) { + EventUtils.synthesizeKey("KEY_Tab", {}, win); + Assert.equal(win.document.activeElement.id, next); + } +} + +async function onboardingClose(variation) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the close button"); + const closeButton = win.document.getElementById("onboardingClose"); + Assert.ok(BrowserTestUtils.is_visible(closeButton)); + Assert.equal(closeButton.getAttribute("title"), "Close"); + + info("Commit the close button"); + userAction(closeButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "close_1", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "close_1", + }, + ], + }); +} + +async function onboardingNext(variation) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the next button"); + const nextButton = win.document.getElementById("onboardingNext"); + Assert.ok(BrowserTestUtils.is_visible(nextButton)); + + info("Commit the next button"); + userAction(nextButton); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.is_hidden(introductionSection) && + BrowserTestUtils.is_visible(mainSection), + "Wait for the transition" + ); + + info("Exit"); + EventUtils.synthesizeKey("KEY_Escape", {}, win); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); +} + +async function onboardingAccept(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the accept option and submit button"); + const acceptOption = win.document.getElementById("onboardingAccept"); + const submitButton = win.document.getElementById("onboardingSubmit"); + Assert.ok(acceptOption); + Assert.ok(submitButton.disabled); + + info("Select the accept option"); + userAction(acceptOption); + + info("Commit the submit button"); + Assert.ok(!submitButton.disabled); + userAction(submitButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "accept_2", + expectedUserBranchPrefs: { + "quicksuggest.onboardingDialogVersion": JSON.stringify({ version: 1 }), + "quicksuggest.dataCollection.enabled": true, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "enabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "accept_2", + }, + ], + }); +} + +async function onboardingReject(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the reject option and submit button"); + const rejectOption = win.document.getElementById("onboardingReject"); + const submitButton = win.document.getElementById("onboardingSubmit"); + Assert.ok(rejectOption); + Assert.ok(submitButton.disabled); + + info("Select the reject option"); + userAction(rejectOption); + + info("Commit the submit button"); + Assert.ok(!submitButton.disabled); + userAction(submitButton); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "reject_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "reject_2", + }, + ], + }); +} + +async function onboardingSkipLink(variation, skipIntroduction) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the skip link"); + const skipLink = win.document.getElementById("onboardingSkipLink"); + Assert.ok(BrowserTestUtils.is_visible(skipLink)); + + info("Commit the skip link"); + const tabCount = gBrowser.tabs.length; + userAction(skipLink); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + + info("Check the current tab status"); + Assert.equal( + gBrowser.currentURI.spec, + "about:blank", + "Nothing loaded in the current tab" + ); + Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened"); + }, + variation, + skipIntroduction, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + onboardingDialogChoice: "not_now_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "not_now_2", + }, + ], + }); +} + +async function onboardingLearnMore(variation, skipIntroduction) { + await doLearnMoreTest( + variation, + skipIntroduction, + "onboardingLearnMore", + "learn_more_2" + ); +} + +async function onboardingLearnMoreOnIntroduction(variation, skipIntroduction) { + await doLearnMoreTest( + variation, + skipIntroduction, + "onboardingLearnMoreOnIntroduction", + "learn_more_1" + ); +} + +async function doLearnMoreTest(variation, skipIntroduction, target, telemetry) { + await doActionTest({ + callback: async (win, userAction, maybeShowPromise) => { + info("Check the status of the learn more link"); + const learnMoreLink = win.document.getElementById(target); + Assert.ok(BrowserTestUtils.is_visible(learnMoreLink)); + + info("Commit the learn more link"); + const loadPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + QuickSuggest.HELP_URL + ).then(tab => { + info("Saw new tab"); + return tab; + }); + userAction(learnMoreLink); + + info("Waiting for maybeShowOnboardingDialog to finish"); + await maybeShowPromise; + + info("Waiting for new tab"); + let tab = await loadPromise; + + info("Check the current tab status"); + Assert.equal(gBrowser.selectedTab, tab, "Current tab is the new tab"); + Assert.equal( + gBrowser.currentURI.spec, + QuickSuggest.HELP_URL, + "Current tab is the support page" + ); + BrowserTestUtils.removeTab(tab); + }, + variation, + skipIntroduction, + onboardingDialogChoice: telemetry, + onboardingDialogVersion: JSON.stringify({ + version: 1, + variation: variation.name.toLowerCase(), + }), + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: telemetry, + }, + ], + }); +} + +async function doActionTest({ + variation, + skipIntroduction, + callback, + onboardingDialogVersion, + onboardingDialogChoice, + expectedUserBranchPrefs, + telemetryEvents, +}) { + const userClick = target => { + info("Click on the target"); + target.click(); + }; + const userEnter = target => { + target.focus(); + if (target.type === "radio") { + info("Space on the target"); + EventUtils.synthesizeKey("VK_SPACE", {}, target.ownerGlobal); + } else { + info("Enter on the target"); + EventUtils.synthesizeKey("KEY_Enter", {}, target.ownerGlobal); + } + }; + + for (const userAction of [userClick, userEnter]) { + UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog"); + UrlbarPrefs.clear("quicksuggest.seenRestarts", 0); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides: { + quickSuggestScenario: "online", + quickSuggestOnboardingDialogVariation: variation.name, + }, + callback: async () => { + await doDialogTest({ + callback: async () => { + info("Calling showOnboardingDialog"); + const { win, maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction, + }); + + await callback(win, userAction, maybeShowPromise); + }, + onboardingDialogVersion, + onboardingDialogChoice, + expectedUserBranchPrefs, + telemetryEvents, + }); + }, + }); + } +} + +async function doDialogTest({ + callback, + onboardingDialogVersion, + onboardingDialogChoice, + telemetryEvents, + expectedUserBranchPrefs, +}) { + setDialogPrereqPrefs(); + + // Set initial prefs on the default branch. + let initialDefaultBranch = OFFLINE_DEFAULT_PREFS; + let originalDefaultBranch = {}; + for (let [name, value] of Object.entries(initialDefaultBranch)) { + originalDefaultBranch = gDefaultBranch.getBoolPref(name); + gDefaultBranch.setBoolPref(name, value); + gUserBranch.clearUserPref(name); + } + + // Setting the prefs just now triggered telemetry events, so clear them + // before calling the callback. + Services.telemetry.clearEvents(); + + // Call the callback, which should trigger the dialog and interact with it. + await BrowserTestUtils.withNewTab("about:blank", async () => { + await callback(); + }); + + // Now check all pref values on the default and user branches. + for (let [name, value] of Object.entries(initialDefaultBranch)) { + Assert.equal( + gDefaultBranch.getBoolPref(name), + value, + "Default-branch value for pref did not change after modal: " + name + ); + + let effectiveValue; + if (name in expectedUserBranchPrefs) { + effectiveValue = expectedUserBranchPrefs[name]; + Assert.equal( + gUserBranch.getBoolPref(name), + effectiveValue, + "User-branch value for pref has expected value: " + name + ); + } else { + effectiveValue = value; + Assert.ok( + !gUserBranch.prefHasUserValue(name), + "User-branch value for pref does not exist: " + name + ); + } + + // For good measure, check the value returned by UrlbarPrefs. + Assert.equal( + UrlbarPrefs.get(name), + effectiveValue, + "Effective value for pref is correct: " + name + ); + } + + Assert.equal( + UrlbarPrefs.get("quicksuggest.onboardingDialogVersion"), + onboardingDialogVersion, + "onboardingDialogVersion" + ); + Assert.equal( + UrlbarPrefs.get("quicksuggest.onboardingDialogChoice"), + onboardingDialogChoice, + "onboardingDialogChoice" + ); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.quicksuggest.onboardingDialogChoice" + ], + onboardingDialogChoice, + "onboardingDialogChoice is correct in TelemetryEnvironment" + ); + + QuickSuggestTestUtils.assertEvents(telemetryEvents); + + Assert.ok( + UrlbarPrefs.get("quicksuggest.showedOnboardingDialog"), + "quicksuggest.showedOnboardingDialog is true after showing dialog" + ); + + // Clean up. + for (let [name, value] of Object.entries(originalDefaultBranch)) { + gDefaultBranch.setBoolPref(name, value); + } + for (let name of Object.keys(expectedUserBranchPrefs)) { + gUserBranch.clearUserPref(name); + } +} + +/** + * Show onbaording dialog. + * + * @param {object} [options] + * The object options. + * @param {boolean} [options.skipIntroduction] + * If true, return dialog with skipping the introduction section. + * @returns {{ window, maybeShowPromise: Promise }} + * win: window object of the dialog. + * maybeShowPromise: Promise of QuickSuggest.maybeShowOnboardingDialog(). + */ +async function showOnboardingDialog({ skipIntroduction } = {}) { + const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen( + null, + QuickSuggest.ONBOARDING_URI, + { isSubDialog: true } + ); + + const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog(); + + const win = await dialogPromise; + if (win.document.readyState != "complete") { + await BrowserTestUtils.waitForEvent(win, "load"); + } + + // Wait until all listers on onboarding dialog are ready. + await window._quicksuggestOnboardingReady; + + if (!skipIntroduction) { + return { win, maybeShowPromise }; + } + + // Trigger the transition by pressing Enter on the Next button. + EventUtils.synthesizeKey("KEY_Enter"); + + const introductionSection = win.document.getElementById( + "introduction-section" + ); + const mainSection = win.document.getElementById("main-section"); + + await BrowserTestUtils.waitForCondition( + () => + BrowserTestUtils.is_hidden(introductionSection) && + BrowserTestUtils.is_visible(mainSection) + ); + + return { win, maybeShowPromise }; +} + +/** + * Sets all the required prefs for showing the onboarding dialog except for the + * prefs that are set when the dialog is accepted. + */ +function setDialogPrereqPrefs() { + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", true); + UrlbarPrefs.set("quicksuggest.showedOnboardingDialog", false); +} + +/** + * This is a real hacky way of determining whether the tab key can move focus. + * Windows and Linux both support it but macOS does not unless full keyboard + * access is enabled, so practically this is only useful on macOS. Gecko seems + * to know whether full keyboard access is enabled because it affects focus in + * Firefox and some code in nsXULElement.cpp and other places mention it, but + * there doesn't seem to be a way to access that information from JS. There is + * `Services.focus.elementIsFocusable`, but it returns true regardless of + * whether full access is enabled. + * + * So what we do here is open the dialog and synthesize a tab key. If the focus + * doesn't change, then we assume moving the focus via the tab key is not + * supported. + * + * Why not just always skip the focus tasks on Mac? Because individual + * developers (like the one writing this comment) may be running macOS with full + * keyboard access enabled and want to actually run the tasks on their machines. + * + * @returns {boolean} + */ +async function canTabMoveFocus() { + if (AppConstants.platform != "macosx") { + return true; + } + + let canMove = false; + await doDialogTest({ + callback: async () => { + const { win, maybeShowPromise } = await showOnboardingDialog({ + skipIntroduction: true, + }); + + let doc = win.document; + doc.getElementById("onboardingAccept").focus(); + EventUtils.synthesizeKey("KEY_Tab"); + + // Whether or not the focus can move to the link. + canMove = doc.activeElement.id === "onboardingLearnMore"; + + EventUtils.synthesizeKey("KEY_Escape"); + await maybeShowPromise; + }, + onboardingDialogVersion: JSON.stringify({ version: 1 }), + onboardingDialogChoice: "dismiss_2", + expectedUserBranchPrefs: { + "quicksuggest.dataCollection.enabled": false, + }, + telemetryEvents: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: "disabled", + }, + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "opt_in_dialog", + object: "dismiss_2", + }, + ], + }); + + return canMove; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js new file mode 100644 index 0000000000..ece6239953 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for dynamic Wikipedia suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const MERINO_SUGGESTION = { + block_id: 1, + url: "https://example.com/dynamic-wikipedia", + title: "Dynamic Wikipedia suggestion", + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "dynamic-wikipedia", + provider: "wikipedia", + iab_category: "5 - Education", +}; + +const suggestion_type = "dynamic-wikipedia"; +const match_type = "firefox-suggest"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await setUpTelemetryTest({ + merinoSuggestions: [MERINO_SUGGESTION], + }); +}); + +add_task(async function () { + await doTelemetryTest({ + index, + suggestion: MERINO_SUGGESTION, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + [TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA]: position, + "urlbar.picked.dynamic_wikipedia": index.toString(), + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // block + "urlbarView-button-block": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + [TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // help + "urlbarView-button-help": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position, + [TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + }, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js new file mode 100644 index 0000000000..c52d22a886 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js @@ -0,0 +1,477 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests abandonment and edge cases related to impressions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + }, + { + id: 2, + url: "https://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + iab_category: "5 - Education", + }, +]; + +const SPONSORED_RESULT = REMOTE_SETTINGS_RESULTS[0]; + +// Spy for the custom impression/click sender +let spy; + +add_setup(async function () { + ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy()); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Makes sure impression telemetry is not recorded when the urlbar engagement is +// abandoned. +add_task(async function abandonment() { + Services.telemetry.clearEvents(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "sponsored", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + url: SPONSORED_RESULT.url, + }); + await UrlbarTestUtils.promisePopupClose(window, () => { + gURLBar.blur(); + }); + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + QuickSuggestTestUtils.assertPings(spy, []); +}); + +// Makes sure impression telemetry is not recorded when a quick suggest result +// is not present. +add_task(async function noQuickSuggestResult() { + await BrowserTestUtils.withNewTab("about:blank", async () => { + Services.telemetry.clearEvents(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "noImpression_noQuickSuggestResult", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + QuickSuggestTestUtils.assertPings(spy, []); + }); + await PlacesUtils.history.clear(); +}); + +// When a quick suggest result is added to the view but hidden during the view +// update, impression telemetry should not be recorded for it. +add_task(async function hiddenRow() { + Services.telemetry.clearEvents(); + + // Increase the timeout of the remove-stale-rows timer so that it doesn't + // interfere with this task. + let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout; + UrlbarView.removeStaleRowsTimeout = 30000; + registerCleanupFunction(() => { + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; + }); + + // Set up a test provider that doesn't add any results until we resolve its + // `finishQueryPromise`. For the first search below, it will add many search + // suggestions. + let maxCount = UrlbarPrefs.get("maxRichResults"); + let results = []; + for (let i = 0; i < maxCount; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "Example", + suggestion: "suggestion " + i, + lowerCaseSuggestion: "suggestion " + i, + query: "test", + } + ) + ); + } + let provider = new DelayingTestProvider({ results }); + UrlbarProvidersManager.registerProvider(provider); + + // Open a new tab since we'll load a page below. + let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser }); + + // Do a normal search and allow the test provider to finish. + provider.finishQueryPromise = Promise.resolve(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + fireInputEvent: true, + }); + + // Sanity check the rows. After the heuristic, the remaining rows should be + // the search results added by the test provider. + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxCount, + "Row count after first search" + ); + for (let i = 1; i < maxCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "Expected result type at index " + i + ); + Assert.equal( + result.source, + UrlbarUtils.RESULT_SOURCE.SEARCH, + "Expected result source at index " + i + ); + } + + // Now set up a second search that triggers a quick suggest result. Add a + // mutation listener to the view so we can tell when the quick suggest row is + // added. + let mutationPromise = new Promise(resolve => { + let observer = new MutationObserver(mutations => { + let rows = UrlbarTestUtils.getResultsContainer(window).children; + for (let row of rows) { + if (row.result.providerName == "UrlbarProviderQuickSuggest") { + observer.disconnect(); + resolve(row); + return; + } + } + }); + observer.observe(UrlbarTestUtils.getResultsContainer(window), { + childList: true, + }); + }); + + // Set the test provider's `finishQueryPromise` to a promise that doesn't + // resolve. That will prevent the search from completing, which will prevent + // the view from removing stale rows and showing the quick suggest row. + let resolveQuery; + provider.finishQueryPromise = new Promise( + resolve => (resolveQuery = resolve) + ); + + // Start the second search but don't wait for it to finish. + gURLBar.focus(); + let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: REMOTE_SETTINGS_RESULTS[0].keywords[0], + fireInputEvent: true, + }); + + // Wait for the quick suggest row to be added to the view. It should be hidden + // because (a) quick suggest results have a `suggestedIndex`, and rows with + // suggested indexes can't replace rows without suggested indexes, and (b) the + // view already contains the maximum number of rows due to the first search. + // It should remain hidden until the search completes or the remove-stale-rows + // timer fires. Next, we'll hit enter, which will cancel the search and close + // the view, so the row should never appear. + let quickSuggestRow = await mutationPromise; + Assert.ok( + BrowserTestUtils.is_hidden(quickSuggestRow), + "Quick suggest row is hidden" + ); + + // Hit enter to pick the heuristic search result. This will cancel the search + // and notify the quick suggest provider that an engagement occurred. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + await loadPromise; + + // Resolve the test provider's promise finally. + resolveQuery(); + await queryPromise; + + // The quick suggest provider added a result but it wasn't visible in the + // view. No impression telemetry should be recorded for it. + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + QuickSuggestTestUtils.assertPings(spy, []); + + BrowserTestUtils.removeTab(tab); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout; +}); + +// When a quick suggest result has not been added to the view, impression +// telemetry should not be recorded for it even if it's the result most recently +// returned by the provider. +add_task(async function notAddedToView() { + Services.telemetry.clearEvents(); + + // Open a new tab since we'll load a page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do an initial search that doesn't match any suggestions to make sure + // there aren't any quick suggest results in the view to start. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "this doesn't match anything", + fireInputEvent: true, + }); + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + await UrlbarTestUtils.promisePopupClose(window); + + // Now do a search for a suggestion and hit enter after the provider adds it + // but before it appears in the view. + await doEngagementWithoutAddingResultToView( + REMOTE_SETTINGS_RESULTS[0].keywords[0] + ); + + // The quick suggest provider added a result but it wasn't visible in the + // view, and no other quick suggest results were visible in the view. No + // impression telemetry should be recorded. + QuickSuggestTestUtils.assertScalars({}); + QuickSuggestTestUtils.assertEvents([]); + QuickSuggestTestUtils.assertPings(spy, []); + }); +}); + +// When a quick suggest result is visible in the view, impression telemetry +// should be recorded for it even if it's not the result most recently returned +// by the provider. +add_task(async function previousResultStillVisible() { + Services.telemetry.clearEvents(); + + // Open a new tab since we'll load a page. + await BrowserTestUtils.withNewTab("about:blank", async () => { + // Do a search for the first suggestion. + let firstSuggestion = REMOTE_SETTINGS_RESULTS[0]; + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstSuggestion.keywords[0], + fireInputEvent: true, + }); + + let index = 1; + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index, + url: firstSuggestion.url, + }); + + // Without closing the view, do a second search for the second suggestion + // and hit enter after the provider adds it but before it appears in the + // view. + await doEngagementWithoutAddingResultToView( + REMOTE_SETTINGS_RESULTS[1].keywords[0], + index + ); + + // An impression for the first suggestion should be recorded since it's + // still visible in the view, not the second suggestion. + QuickSuggestTestUtils.assertScalars({ + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: index + 1, + }); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + match_type: "firefox-suggest", + position: String(index + 1), + suggestion_type: "sponsored", + }, + }, + ]); + QuickSuggestTestUtils.assertPings(spy, [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + improve_suggest_experience_checked: false, + block_id: firstSuggestion.id, + is_clicked: false, + match_type: "firefox-suggest", + position: index + 1, + }, + }, + ]); + }); +}); + +/** + * Does a search that causes the quick suggest provider to return a result + * without adding it to the view and then hits enter to load a SERP and create + * an engagement. + * + * @param {string} searchString + * The search string. + * @param {number} previousResultIndex + * If the view is already open and showing a quick suggest result, pass its + * index here. Otherwise pass -1. + */ +async function doEngagementWithoutAddingResultToView( + searchString, + previousResultIndex = -1 +) { + // Set the timeout of the chunk timer to a really high value so that it will + // not fire. The view updates when the timer fires, which we specifically want + // to avoid here. + let originalChunkDelayMs = UrlbarProvidersManager._chunkResultsDelayMs; + UrlbarProvidersManager._chunkResultsDelayMs = 30000; + registerCleanupFunction(() => { + UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs; + }); + + // Stub `UrlbarProviderQuickSuggest.getPriority()` to return Infinity. + let sandbox = sinon.createSandbox(); + let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority"); + getPriorityStub.returns(Infinity); + + // Spy on `UrlbarProviderQuickSuggest.onEngagement()`. + let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement"); + + let sandboxCleanup = () => { + getPriorityStub?.restore(); + getPriorityStub = null; + sandbox?.restore(); + sandbox = null; + }; + registerCleanupFunction(sandboxCleanup); + + // In addition to setting the chunk timeout to a large value above, in order + // to prevent the view from updating there also needs to be a heuristic + // provider that takes a long time to add results. Set one up that doesn't add + // any results until we resolve its `finishQueryPromise`. Set its priority to + // Infinity too so that only it and the quick suggest provider will be active. + let provider = new DelayingTestProvider({ + results: [], + priority: Infinity, + type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC, + }); + UrlbarProvidersManager.registerProvider(provider); + + let resolveQuery; + provider.finishQueryPromise = new Promise(r => (resolveQuery = r)); + + // Add a query listener so we can grab the query context. + let context; + let queryListener = { + onQueryStarted: c => (context = c), + }; + gURLBar.controller.addQueryListener(queryListener); + + // Do a search but don't wait for it to finish. + gURLBar.focus(); + UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchString, + fireInputEvent: true, + }); + + // Wait for the quick suggest provider to add its result to `context.unsortedResults`. + let result = await TestUtils.waitForCondition(() => { + let query = UrlbarProvidersManager.queries.get(context); + return query?.unsortedResults.find( + r => r.providerName == "UrlbarProviderQuickSuggest" + ); + }, "Waiting for quick suggest result to be added to context.unsortedResults"); + + gURLBar.controller.removeQueryListener(queryListener); + + // The view should not have updated, so the result's `rowIndex` should still + // have its initial value of -1. + Assert.equal(result.rowIndex, -1, "result.rowIndex is still -1"); + + // If there's a result from the previous query, assert it's still in the + // view. Otherwise assume that the view should be closed. These are mostly + // sanity checks because they should only fail if the telemetry assertions + // below also fail. + if (previousResultIndex >= 0) { + let rows = gURLBar.view.panel.querySelector(".urlbarView-results"); + Assert.equal( + rows.children[previousResultIndex].result.providerName, + "UrlbarProviderQuickSuggest", + "Result already in view is a quick suggest" + ); + } else { + Assert.ok(!gURLBar.view.isOpen, "View is closed"); + } + + // Hit enter to load a SERP for the search string. This should notify the + // quick suggest provider that an engagement occurred. + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Enter"); + }); + await loadPromise; + + let engagementCalls = onEngagementSpy.getCalls().filter(call => { + let state = call.args[1]; + return state == "engagement"; + }); + Assert.equal(engagementCalls.length, 1, "One engagement occurred"); + + // Clean up. + resolveQuery(); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs; + sandboxCleanup(); +} + +/** + * A test provider that doesn't finish `startQuery()` until `finishQueryPromise` + * is resolved. + */ +class DelayingTestProvider extends UrlbarTestUtils.TestProvider { + finishQueryPromise = null; + async startQuery(context, addCallback) { + for (let result of this._results) { + addCallback(this, result); + } + await this.finishQueryPromise; + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js new file mode 100644 index 0000000000..defb9bb76d --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js @@ -0,0 +1,353 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for navigational suggestions, a.k.a. + * navigational top picks. + */ + +"use strict"; + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const MERINO_SUGGESTION = { + title: "Navigational suggestion", + url: "https://example.com/navigational-suggestion", + provider: "top_picks", + is_sponsored: false, + score: 0.25, + block_id: 0, + is_top_pick: true, +}; + +const suggestion_type = "navigational"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // `bestMatch.enabled` must be set to show nav suggestions with the best + // match UI treatment. + ["browser.urlbar.bestMatch.enabled", true], + // Disable tab-to-search since like best match it's also shown with + // `suggestedIndex` = 1. + ["browser.urlbar.suggest.engines", false], + ], + }); + + await setUpTelemetryTest({ + merinoSuggestions: [MERINO_SUGGESTION], + }); +}); + +// Clicks the heuristic when a nav suggestion is not matched +add_task(async function notMatched_clickHeuristic() { + await doTest({ + suggestion: null, + shouldBeShown: false, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED]: "search_engine", + }, + events: [], + }); +}); + +// Clicks a non-heuristic row when a nav suggestion is not matched +add_task(async function notMatched_clickOther() { + await PlacesTestUtils.addVisits("http://mochi.test:8888/example"); + await doTest({ + suggestion: null, + shouldBeShown: false, + pickRowIndex: 1, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine", + }, + events: [], + }); +}); + +// Clicks the heuristic when a nav suggestion is shown +add_task(async function shown_clickHeuristic() { + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC]: "search_engine", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks the nav suggestion +add_task(async function shown_clickNavSuggestion() { + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV]: "search_engine", + "urlbar.picked.navigational": "1", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks a non-heuristic non-nav-suggestion row when the nav suggestion is +// shown +add_task(async function shown_clickOther() { + await PlacesTestUtils.addVisits("http://mochi.test:8888/example"); + await doTest({ + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: 2, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine", + }, + events: [ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Clicks the heuristic when it dupes the nav suggestion +add_task(async function duped_clickHeuristic() { + // Add enough visits to example.com so it autofills. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Set the nav suggestion's URL to the same URL, example.com. + let suggestion = { + ...MERINO_SUGGESTION, + url: "https://example.com/", + }; + + await doTest({ + suggestion, + shouldBeShown: false, + pickRowIndex: 0, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin", + [TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED]: "autofill_origin", + }, + events: [], + }); +}); + +// Clicks a non-heuristic row when the heuristic dupes the nav suggestion +add_task(async function duped_clickOther() { + // Add enough visits to example.com so it autofills. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Set the nav suggestion's URL to the same URL, example.com. + let suggestion = { + ...MERINO_SUGGESTION, + url: "https://example.com/", + }; + + // Add a visit to another URL so it appears in the search below. + await PlacesTestUtils.addVisits("https://example.com/some-other-url"); + + await doTest({ + suggestion, + shouldBeShown: false, + pickRowIndex: 1, + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin", + }, + events: [], + }); +}); + +// Telemetry specific to nav suggestions should not be recorded when the +// `recordNavigationalSuggestionTelemetry` Nimbus variable is false. +add_task(async function recordNavigationalSuggestionTelemetry_false() { + await doTest({ + valueOverrides: { + recordNavigationalSuggestionTelemetry: false, + }, + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: {}, + events: [ + // The legacy engagement event should still be recorded as it is for all + // quick suggest results. + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +// Telemetry specific to nav suggestions should not be recorded when the +// `recordNavigationalSuggestionTelemetry` Nimbus variable is left out. +add_task(async function recordNavigationalSuggestionTelemetry_undefined() { + await doTest({ + valueOverrides: {}, + suggestion: MERINO_SUGGESTION, + shouldBeShown: true, + pickRowIndex: index, + scalars: {}, + events: [ + // The legacy engagement event should still be recorded as it is for all + // quick suggest results. + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type: "best-match", + position: position.toString(), + source: "merino", + }, + }, + ], + }); +}); + +/** + * Does the following: + * + * 1. Sets up a Merino nav suggestion + * 2. Enrolls in a Nimbus experiment with the specified variables + * 3. Does a search + * 4. Makes sure the nav suggestion is or isn't shown as expected + * 5. Clicks a specified row + * 6. Makes sure the expected telemetry is recorded + * + * @param {object} options + * Options object + * @param {object} options.suggestion + * The nav suggestion or null if Merino shouldn't serve one. + * @param {boolean} options.shouldBeShown + * Whether the nav suggestion is expected to be shown. + * @param {number} options.pickRowIndex + * The index of the row to pick. + * @param {object} options.scalars + * An object that specifies the nav suggest keyed scalars that are expected to + * be recorded. + * @param {Array} options.events + * An object that specifies the legacy engagement events that are expected to + * be recorded. + * @param {object} options.valueOverrides + * The Nimbus variables to use. + */ +async function doTest({ + suggestion, + shouldBeShown, + pickRowIndex, + scalars, + events, + valueOverrides = { + recordNavigationalSuggestionTelemetry: true, + }, +}) { + MerinoTestUtils.server.response.body.suggestions = suggestion + ? [suggestion] + : []; + + Services.telemetry.clearEvents(); + let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy(); + + await QuickSuggestTestUtils.withExperiment({ + valueOverrides, + callback: async () => { + await BrowserTestUtils.withNewTab("about:blank", async () => { + gURLBar.focus(); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example", + fireInputEvent: true, + }); + + if (shouldBeShown) { + await QuickSuggestTestUtils.assertIsQuickSuggest({ + window, + index, + url: suggestion.url, + isBestMatch: true, + isSponsored: false, + }); + } else { + await QuickSuggestTestUtils.assertNoQuickSuggestResults(window); + } + + let loadPromise = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + if (pickRowIndex > 0) { + info("Arrowing down to row index " + pickRowIndex); + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: pickRowIndex }); + } + info("Pressing Enter and waiting for page load"); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + }); + }, + }); + + info("Checking scalars"); + QuickSuggestTestUtils.assertScalars(scalars); + + info("Checking events"); + QuickSuggestTestUtils.assertEvents(events); + + info("Checking pings"); + QuickSuggestTestUtils.assertPings(spy, []); + + await spyCleanup(); + await PlacesUtils.history.clear(); + MerinoTestUtils.server.response.body.suggestions = [MERINO_SUGGESTION]; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js new file mode 100644 index 0000000000..c7ddcdd2ce --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js @@ -0,0 +1,368 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for nonsponsored suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULT = { + id: 1, + url: "https://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + iab_category: "5 - Education", +}; + +const suggestion_type = "nonsponsored"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await setUpTelemetryTest({ + remoteSettingsResults: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_RESULT], + }, + ], + }); +}); + +// nonsponsored +add_task(async function nonsponsored() { + let match_type = "firefox-suggest"; + + // Make sure `improve_suggest_experience_checked` is recorded correctly + // depending on the value of the related pref. + for (let improve_suggest_experience_checked of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.quicksuggest.dataCollection.enabled", + improve_suggest_experience_checked, + ], + ], + }); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: true, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + // block + "urlbarView-button-block": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + match_type, + position, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + "urlbarView-button-help": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.HELP_NONSPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + }, + }); + await SpecialPowers.popPrefEnv(); + } +}); + +// nonsponsored best match +add_task(async function nonsponsoredBestMatch() { + let match_type = "best-match"; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", true]], + }); + await QuickSuggestTestUtils.setConfig( + QuickSuggestTestUtils.BEST_MATCH_CONFIG + ); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.CLICK_NONSPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_NONSPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: true, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + match_type, + position, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + // block + "urlbarView-button-block": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.BLOCK_NONSPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + match_type, + position, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + "urlbarView-button-help": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.HELP_NONSPONSORED]: position, + [TELEMETRY_SCALARS.HELP_NONSPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + }, + }); + await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js new file mode 100644 index 0000000000..d151ca81ad --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js @@ -0,0 +1,409 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests ancillary quick suggest telemetry, i.e., telemetry that's not + * strongly related to showing suggestions in the urlbar. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", +}); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", + }, +]; + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +// Tests telemetry recorded when toggling the +// `suggest.quicksuggest.nonsponsored` pref: +// * contextservices.quicksuggest enable_toggled event telemetry +// * TelemetryEnvironment +add_task(async function enableToggled() { + Services.telemetry.clearEvents(); + + // Toggle the suggest.quicksuggest.nonsponsored pref twice. We should get two + // events. + let enabled = UrlbarPrefs.get("suggest.quicksuggest.nonsponsored"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "enable_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.suggest.quicksuggest.nonsponsored" + ], + enabled, + "suggest.quicksuggest.nonsponsored is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the + // suggest.quicksuggest.nonsponsored pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", !enabled); +}); + +// Tests telemetry recorded when toggling the `suggest.quicksuggest.sponsored` +// pref: +// * contextservices.quicksuggest enable_toggled event telemetry +// * TelemetryEnvironment +add_task(async function sponsoredToggled() { + Services.telemetry.clearEvents(); + + // Toggle the suggest.quicksuggest.sponsored pref twice. We should get two + // events. + let enabled = UrlbarPrefs.get("suggest.quicksuggest.sponsored"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "sponsored_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.suggest.quicksuggest.sponsored" + ], + enabled, + "suggest.quicksuggest.sponsored is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the + // suggest.quicksuggest.sponsored pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", !enabled); +}); + +// Tests telemetry recorded when toggling the +// `quicksuggest.dataCollection.enabled` pref: +// * contextservices.quicksuggest data_collect_toggled event telemetry +// * TelemetryEnvironment +add_task(async function dataCollectionToggled() { + Services.telemetry.clearEvents(); + + // Toggle the quicksuggest.dataCollection.enabled pref twice. We should get + // two events. + let enabled = UrlbarPrefs.get("quicksuggest.dataCollection.enabled"); + for (let i = 0; i < 2; i++) { + enabled = !enabled; + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled); + QuickSuggestTestUtils.assertEvents([ + { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "data_collect_toggled", + object: enabled ? "enabled" : "disabled", + }, + ]); + Assert.equal( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + "browser.urlbar.quicksuggest.dataCollection.enabled" + ], + enabled, + "quicksuggest.dataCollection.enabled is correct in TelemetryEnvironment" + ); + } + + // Set the main quicksuggest.enabled pref to false and toggle the data + // collection pref again. We shouldn't get any events. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.quicksuggest.enabled", false]], + }); + enabled = !enabled; + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled); + QuickSuggestTestUtils.assertEvents([]); + await SpecialPowers.popPrefEnv(); + + // Set the pref back to what it was at the start of the task. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", !enabled); +}); + +// Tests telemetry recorded when clicking the checkbox for best match in +// preferences UI. The telemetry will be stored as following keyed scalar. +// scalar: browser.ui.interaction.preferences_panePrivacy +// key: firefoxSuggestBestMatch +add_task(async function bestmatchCheckbox() { + // Set the initial enabled status. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", true]], + }); + + // Open preferences page for best match. + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences#privacy", + true + ); + + for (let i = 0; i < 2; i++) { + Services.telemetry.clearScalars(); + + // Click on the checkbox. + const doc = gBrowser.selectedBrowser.contentDocument; + const checkboxId = "firefoxSuggestBestMatch"; + const checkbox = doc.getElementById(checkboxId); + checkbox.scrollIntoView(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#" + checkboxId, + {}, + gBrowser.selectedBrowser + ); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.ui.interaction.preferences_panePrivacy", + checkboxId, + 1 + ); + } + + // Clean up. + gBrowser.removeCurrentTab(); + await SpecialPowers.popPrefEnv(); +}); + +// Tests telemetry recorded when opening the learn more link for best match in +// the preferences UI. The telemetry will be stored as following keyed scalar. +// scalar: browser.ui.interaction.preferences_panePrivacy +// key: firefoxSuggestBestMatchLearnMore +add_task(async function bestmatchLearnMore() { + // Set the initial enabled status. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", true]], + }); + + // Open preferences page for best match. + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences#privacy", + true + ); + + // Click on the learn more link. + Services.telemetry.clearScalars(); + const learnMoreLinkId = "firefoxSuggestBestMatchLearnMore"; + const doc = gBrowser.selectedBrowser.contentDocument; + const link = doc.getElementById(learnMoreLinkId); + link.scrollIntoView(); + const onLearnMoreOpenedByClick = BrowserTestUtils.waitForNewTab( + gBrowser, + QuickSuggest.HELP_URL + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#" + learnMoreLinkId, + {}, + gBrowser.selectedBrowser + ); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.ui.interaction.preferences_panePrivacy", + "firefoxSuggestBestMatchLearnMore", + 1 + ); + await onLearnMoreOpenedByClick; + gBrowser.removeCurrentTab(); + + // Type enter key on the learm more link. + Services.telemetry.clearScalars(); + link.focus(); + const onLearnMoreOpenedByKey = BrowserTestUtils.waitForNewTab( + gBrowser, + QuickSuggest.HELP_URL + ); + await BrowserTestUtils.synthesizeKey( + "KEY_Enter", + {}, + gBrowser.selectedBrowser + ); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.ui.interaction.preferences_panePrivacy", + "firefoxSuggestBestMatchLearnMore", + 1 + ); + await onLearnMoreOpenedByKey; + gBrowser.removeCurrentTab(); + + // Clean up. + gBrowser.removeCurrentTab(); + await SpecialPowers.popPrefEnv(); +}); + +// Simulates the race on startup between telemetry environment initialization +// and the initial update of the Suggest scenario. After startup is done, +// telemetry environment should record the correct values for startup prefs. +add_task(async function telemetryEnvironmentOnStartup() { + await QuickSuggestTestUtils.setScenario(null); + + // Restart telemetry environment so we know it's watching its default set of + // prefs. + await TelemetryEnvironment.testCleanRestart().onInitialized(); + + // Get the prefs that UrlbarPrefs sets when the Suggest scenario is updated on + // startup. They're the union of the prefs exposed in the UI and the prefs + // that are set on the default branch per scenario. + let prefs = [ + ...new Set([ + ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE), + ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS) + .map(valuesByPrefName => Object.keys(valuesByPrefName)) + .flat(), + ]), + ]; + + // Not all of the prefs are recorded in telemetry environment. Filter in the + // ones that are. + prefs = prefs.filter( + p => + `browser.urlbar.${p}` in + TelemetryEnvironment.currentEnvironment.settings.userPrefs + ); + + info("Got startup prefs: " + JSON.stringify(prefs)); + + // Sanity check the expected prefs. This isn't strictly necessary since we + // programmatically get the prefs above, but it's an extra layer of defense, + // for example in case we accidentally filtered out some expected prefs above. + // If this fails, you might have added a startup pref but didn't update this + // array here. + Assert.deepEqual( + prefs.sort(), + [ + "quicksuggest.dataCollection.enabled", + "suggest.quicksuggest.nonsponsored", + "suggest.quicksuggest.sponsored", + ], + "Expected startup prefs" + ); + + // Make sure the prefs don't have user values that would mask the default + // values. + for (let p of prefs) { + UrlbarPrefs.clear(p); + } + + // Build a map of default values. + let defaultValues = Object.fromEntries( + prefs.map(p => [p, UrlbarPrefs.get(p)]) + ); + + // Now simulate startup. Restart telemetry environment but don't wait for it + // to finish before calling `updateFirefoxSuggestScenario()`. This simulates + // startup where telemetry environment's initialization races the intial + // update of the Suggest scenario. + let environmentInitPromise = + TelemetryEnvironment.testCleanRestart().onInitialized(); + + // Update the scenario and force the startup prefs to take on values that are + // the inverse of what they are now. + await UrlbarPrefs.updateFirefoxSuggestScenario({ + isStartup: true, + scenario: "online", + defaultPrefs: { + online: Object.fromEntries( + Object.entries(defaultValues).map(([p, value]) => [p, !value]) + ), + }, + }); + + // At this point telemetry environment should be done initializing since + // `updateFirefoxSuggestScenario()` waits for it, but await our promise now. + await environmentInitPromise; + + // TelemetryEnvironment should have cached the new values. + for (let [p, value] of Object.entries(defaultValues)) { + let expected = !value; + Assert.strictEqual( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + `browser.urlbar.${p}` + ], + expected, + `Check 1: ${p} is ${expected} in TelemetryEnvironment` + ); + } + + // Simulate another startup and set all prefs back to their original default + // values. + environmentInitPromise = + TelemetryEnvironment.testCleanRestart().onInitialized(); + + await UrlbarPrefs.updateFirefoxSuggestScenario({ + isStartup: true, + scenario: "online", + defaultPrefs: { + online: defaultValues, + }, + }); + + await environmentInitPromise; + + // TelemetryEnvironment should have cached the new (original) values. + for (let [p, value] of Object.entries(defaultValues)) { + let expected = value; + Assert.strictEqual( + TelemetryEnvironment.currentEnvironment.settings.userPrefs[ + `browser.urlbar.${p}` + ], + expected, + `Check 2: ${p} is ${expected} in TelemetryEnvironment` + ); + } + + await TelemetryEnvironment.testCleanRestart().onInitialized(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js new file mode 100644 index 0000000000..498b942a12 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js @@ -0,0 +1,367 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for sponsored suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.sys.mjs", +}); + +const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULT = { + id: 1, + url: "https://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "testadvertiser", +}; + +const suggestion_type = "sponsored"; +const index = 1; +const position = index + 1; + +add_setup(async function () { + await setUpTelemetryTest({ + remoteSettingsResults: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_RESULT], + }, + ], + }); +}); + +// sponsored +add_task(async function sponsored() { + let match_type = "firefox-suggest"; + + // Make sure `improve_suggest_experience_checked` is recorded correctly + // depending on the value of the related pref. + for (let improve_suggest_experience_checked of [false, true]) { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.urlbar.quicksuggest.dataCollection.enabled", + improve_suggest_experience_checked, + ], + ], + }); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: true, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + // block + "urlbarView-button-block": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + match_type, + position, + improve_suggest_experience_checked, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + "urlbarView-button-help": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + improve_suggest_experience_checked, + is_clicked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + }, + }); + await SpecialPowers.popPrefEnv(); + } +}); + +// sponsored best match +add_task(async function sponsoredBestMatch() { + let match_type = "best-match"; + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.bestMatch.enabled", true]], + }); + await QuickSuggestTestUtils.setConfig( + QuickSuggestTestUtils.BEST_MATCH_CONFIG + ); + await doTelemetryTest({ + index, + suggestion: REMOTE_SETTINGS_RESULT, + // impression-only + impressionOnly: { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + ping: { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED]: position, + [TELEMETRY_SCALARS.CLICK_SPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: true, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, + payload: { + match_type, + position, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + // block + "urlbarView-button-block": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position, + [TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, + payload: { + match_type, + position, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + iab_category: REMOTE_SETTINGS_RESULT.iab_category, + }, + }, + ], + }, + // help + "urlbarView-button-help": { + scalars: { + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position, + [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED]: position, + [TELEMETRY_SCALARS.HELP_SPONSORED_BEST_MATCH]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + pings: [ + { + type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, + payload: { + match_type, + position, + is_clicked: false, + improve_suggest_experience_checked: false, + block_id: REMOTE_SETTINGS_RESULT.id, + advertiser: REMOTE_SETTINGS_RESULT.advertiser, + }, + }, + ], + }, + }, + }); + await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js new file mode 100644 index 0000000000..b60fa9fe85 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests primary telemetry for weather suggestions. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const suggestion_type = "weather"; +const match_type = "firefox-suggest"; +const index = 1; +const position = index + 1; + +const { TELEMETRY_SCALARS: WEATHER_SCALARS } = UrlbarProviderWeather; +const { WEATHER_SUGGESTION: suggestion, WEATHER_RS_DATA } = MerinoTestUtils; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Make sure quick actions are disabled because showing them in the top + // sites view interferes with this test. + ["browser.urlbar.suggest.quickactions", false], + ], + }); + + await setUpTelemetryTest({ + suggestions: [], + remoteSettingsResults: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + await MerinoTestUtils.initWeather(); + await updateTopSitesAndAwaitChanged(); +}); + +add_task(async function () { + await doTelemetryTest({ + index, + suggestion, + providerName: UrlbarProviderWeather.name, + showSuggestion: async () => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + }, + teardown: async () => { + // Picking the block button sets this pref to false and disables weather + // suggestions. We need to flip it back to true and wait for the + // suggestion to be fetched again before continuing to the next selectable + // test. The view also also stay open, so close it afterward. + if (!UrlbarPrefs.get("suggest.weather")) { + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.clear("suggest.weather"); + await fetchPromise; + } + }, + // impression-only + impressionOnly: { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "impression_only", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + selectables: { + // click + "urlbarView-row-inner": { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + [WEATHER_SCALARS.CLICK]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "click", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // block + "urlbarView-button-block": { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + [WEATHER_SCALARS.BLOCK]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "block", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + // help + "urlbarView-button-help": { + scalars: { + [WEATHER_SCALARS.IMPRESSION]: position, + [WEATHER_SCALARS.HELP]: position, + }, + event: { + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "engagement", + object: "help", + extra: { + suggestion_type, + match_type, + position: position.toString(), + }, + }, + }, + }, + }); +}); + +async function updateTopSitesAndAwaitChanged() { + let url = "http://mochi.test:8888/topsite"; + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits(url); + } + + info("Updating top sites and awaiting newtab-top-sites-changed"); + let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then( + () => info("Observed newtab-top-sites-changed") + ); + await updateTopSites(sites => sites?.length); + await changedPromise; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js new file mode 100644 index 0000000000..049b25dd09 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js @@ -0,0 +1,379 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Browser test for the weather suggestion. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +add_setup(async function () { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ], + }); + await MerinoTestUtils.initWeather(); +}); + +// This test ensures the browser navigates to the weather webpage after +// the weather result is selected. +add_task(async function test_weather_result_selection() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + false + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + + info(`Select the weather result`); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + info(`Navigate to the weather url`); + EventUtils.synthesizeKey("KEY_Enter"); + + await browserLoadedPromise; + + Assert.equal( + gBrowser.currentURI.spec, + "https://example.com/weather", + "Assert the page navigated to the weather webpage after selecting the weather result." + ); + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); +}); + +// Does a search, clicks the "Show less frequently" result menu command, and +// repeats both steps until the min keyword length cap is reached. +add_task(async function showLessFrequentlyCapReached_manySearches() { + // Set up a min keyword length and cap. + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: { + keywords: ["weather"], + min_keyword_length: 3, + min_keyword_length_cap: 4, + }, + }, + ]); + + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderWeather.name, + "Weather suggestion should be present at expected index after 'wea' search" + ); + + // Click the command. + let command = "show_less_frequently"; + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + Assert.equal( + UrlbarPrefs.get("weather.minKeywordLength"), + 4, + "weather.minKeywordLength should be incremented once" + ); + + // Do the same search again. The suggestion should not appear. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.notEqual( + details.result.providerName, + UrlbarProviderWeather.name, + `Weather suggestion should be absent (checking index ${i})` + ); + } + + // Do a search using one more character. The suggestion should appear. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "weat", + }); + + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderWeather.name, + "Weather suggestion should be present at expected index after 'weat' search" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after 'weat' search" + ); + + // Since the cap has been reached, the command should no longer appear in the + // result menu. + await UrlbarTestUtils.openResultMenu(window, { resultIndex }); + let menuitem = gURLBar.view.resultMenu.querySelector( + `menuitem[data-command=${command}]` + ); + Assert.ok(!menuitem, "Menuitem should be absent"); + gURLBar.view.resultMenu.hidePopup(true); + + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +// Repeatedly clicks the "Show less frequently" result menu command after doing +// a single search until the min keyword length cap is reached. +add_task(async function showLessFrequentlyCapReached_oneSearch() { + // Set up a min keyword length and cap. + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: { + keywords: ["weather"], + min_keyword_length: 3, + min_keyword_length_cap: 6, + }, + }, + ]); + + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "wea", + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderWeather.name, + "Weather suggestion should be present at expected index after 'wea' search" + ); + + let command = "show_less_frequently"; + + for (let i = 0; i < 3; i++) { + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + openByMouse: true, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + Assert.equal( + UrlbarPrefs.get("weather.minKeywordLength"), + 4 + i, + "weather.minKeywordLength should be incremented once" + ); + } + + let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({ + window, + command, + resultIndex, + }); + Assert.ok( + !menuitem, + "The menuitem should not exist after the cap is reached" + ); + + gURLBar.view.resultMenu.hidePopup(true); + await UrlbarTestUtils.promisePopupClose(window); + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +// Tests the "Not interested" result menu dismissal command. +add_task(async function notInterested() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + await doDismissTest("not_interested"); +}); + +// Tests the "Not relevant" result menu dismissal command. +add_task(async function notRelevant() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + await doDismissTest("not_relevant"); +}); + +async function doDismissTest(command) { + let resultCount = UrlbarTestUtils.getResultCount(window); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderWeather.name, + "Weather suggestion should be present" + ); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem( + window, + ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command], + { resultIndex, openByMouse: true } + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.weather"), + "suggest.weather pref should be set to false after dismissal" + ); + + // The row should be a tip now. + Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal"); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount, + "The result count should not haved changed after dismissal" + ); + details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.type, + UrlbarUtils.RESULT_TYPE.TIP, + "Row should be a tip after dismissal" + ); + Assert.equal( + details.result.payload.type, + "dismissalAcknowledgment", + "Tip type should be dismissalAcknowledgment" + ); + Assert.ok( + !details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should not have feedback acknowledgment after dismissal" + ); + + // Get the dismissal acknowledgment's "Got it" button and click it. + let gotItButton = UrlbarTestUtils.getButtonForResultIndex( + window, + "0", + resultIndex + ); + Assert.ok(gotItButton, "Row should have a 'Got it' button"); + EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window); + + // The view should remain open and the tip row should be gone. + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the 'Got it' button" + ); + Assert.equal( + UrlbarTestUtils.getResultCount(window), + resultCount - 1, + "The result count should be one less after clicking 'Got it' button" + ); + for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) { + details = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.ok( + details.type != UrlbarUtils.RESULT_TYPE.TIP && + details.result.providerName != UrlbarProviderWeather.name, + "Tip result and weather result should not be present" + ); + } + + await UrlbarTestUtils.promisePopupClose(window); + + // Enable the weather suggestion again and wait for it to be fetched. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.clear("suggest.weather"); + info("Waiting for weather fetch after re-enabling the suggestion"); + await fetchPromise; + info("Got weather fetch"); +} + +// Tests the "Report inaccurate location" result menu command immediately +// followed by a dismissal command to make sure other commands still work +// properly while the urlbar session remains ongoing. +add_task(async function inaccurateLocationAndDismissal() { + await doSessionOngoingCommandTest("inaccurate_location"); +}); + +// Tests the "Show less frequently" result menu command immediately followed by +// a dismissal command to make sure other commands still work properly while the +// urlbar session remains ongoing. +add_task(async function showLessFrequentlyAndDismissal() { + await doSessionOngoingCommandTest("show_less_frequently"); + UrlbarPrefs.clear("weather.minKeywordLength"); +}); + +async function doSessionOngoingCommandTest(command) { + // Trigger the suggestion. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: MerinoTestUtils.WEATHER_KEYWORD, + }); + + let resultIndex = 1; + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex); + Assert.equal( + details.result.providerName, + UrlbarProviderWeather.name, + "Weather suggestion should be present at expected index after search" + ); + + // Click the command. + await UrlbarTestUtils.openResultMenuAndClickItem(window, command, { + resultIndex, + }); + + Assert.ok( + gURLBar.view.isOpen, + "The view should remain open clicking the command" + ); + Assert.ok( + details.element.row.hasAttribute("feedback-acknowledgment"), + "Row should have feedback acknowledgment after clicking command" + ); + + info("Doing dismissal"); + await doDismissTest("not_interested"); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/head.js b/browser/components/urlbar/tests/quicksuggest/browser/head.js new file mode 100644 index 0000000000..07979080fd --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/head.js @@ -0,0 +1,569 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let sandbox; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +ChromeUtils.defineESModuleGetters(this, { + CONTEXTUAL_SERVICES_PING_TYPES: + "resource:///modules/PartnerLinkAttribution.jsm", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +registerCleanupFunction(async () => { + // Ensure the popup is always closed at the end of each test to avoid + // interfering with the next test. + await UrlbarTestUtils.promisePopupClose(window); +}); + +/** + * Updates the Top Sites feed. + * + * @param {Function} condition + * A callback that returns true after Top Sites are successfully updated. + * @param {boolean} searchShortcuts + * True if Top Sites search shortcuts should be enabled. + */ +async function updateTopSites(condition, searchShortcuts = false) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear", + "", + ], + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + searchShortcuts, + ], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +/** + * Call this in your setup task if you use `doTelemetryTest()`. + * + * @param {object} options + * Options + * @param {Array} options.remoteSettingsResults + * Array of remote settings result objects. If not given, no suggestions + * will be present in remote settings. + * @param {Array} options.merinoSuggestions + * Array of Merino suggestion objects. If given, this function will start + * the mock Merino server and set `quicksuggest.dataCollection.enabled` to + * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it. + * Otherwise Merino will not serve suggestions, but you can still set up + * Merino without using this function by using `MerinoTestUtils` directly. + * @param {Array} options.config + * Quick suggest will be initialized with this config. Leave undefined to use + * the default config. See `QuickSuggestTestUtils` for details. + */ +async function setUpTelemetryTest({ + remoteSettingsResults, + merinoSuggestions = null, + config = QuickSuggestTestUtils.DEFAULT_CONFIG, +}) { + if (UrlbarPrefs.get("resultMenu")) { + todo( + false, + "telemetry for the result menu to be implemented in bug 1790020" + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.resultMenu", false]], + }); + } + await SpecialPowers.pushPrefEnv({ + set: [ + // Enable blocking on primary sponsored and nonsponsored suggestions so we + // can test the block button. + ["browser.urlbar.quicksuggest.blockingEnabled", true], + ["browser.urlbar.bestMatch.blockingEnabled", true], + // Switch-to-tab results can sometimes appear after the test clicks a help + // button and closes the new tab, which interferes with the expected + // indexes of quick suggest results, so disable them. + ["browser.urlbar.suggest.openpage", false], + // Disable the persisted-search-terms search tip because it can interfere. + ["browser.urlbar.tipShownCount.searchTip_persist", 999], + ], + }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + await UrlbarTestUtils.formHistory.clear(); + + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + // Add a mock engine so we don't hit the network. + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults, + merinoSuggestions, + config, + }); +} + +/** + * Main entry point for testing primary telemetry for quick suggest suggestions: + * impressions, clicks, helps, and blocks. This can be used to declaratively + * test all primary telemetry for any suggestion type. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {object} options.impressionOnly + * An object describing the expected impression-only telemetry, i.e., + * telemetry recorded when an impression occurs but not a click. It must have + * the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {object} ping + * The expected recorded custom telemetry ping. If no ping is expected, + * leave this undefined or pass null. + * @param {object} options.selectables + * An object describing the telemetry that's expected to be recorded when each + * selectable element in the suggestion's row is picked. This object maps HTML + * class names to objects. Each property's name must be an HTML class name + * that uniquely identifies a selectable element within the row. The value + * must be an object that describes the telemetry that's expected to be + * recorded when that element is picked, and this inner object must have the + * following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {Array} pings + * A list of expected recorded custom telemetry pings. If no pings are + * expected, pass an empty array. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {Function} options.teardown + * If given, this function will be called after each selectable test. If + * picking an element causes side effects that need to be cleaned up before + * starting the next selectable test, they can be cleaned up here. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doTelemetryTest({ + index, + suggestion, + impressionOnly, + selectables, + providerName = UrlbarProviderQuickSuggest.name, + teardown = null, + showSuggestion = () => + UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + // If the suggestion object is a remote settings result, it will have a + // `keywords` property. Otherwise the suggestion object must be a Merino + // suggestion, and the search string doesn't matter in that case because + // the mock Merino server will be set up to return suggestions regardless. + value: suggestion.keywords?.[0] || "test", + fireInputEvent: true, + }), +}) { + // Do the impression-only test. It will return the `classList` values of all + // the selectable elements in the row so we can use them below. + let selectableClassLists = await doImpressionOnlyTest({ + index, + suggestion, + providerName, + showSuggestion, + expected: impressionOnly, + }); + if (!selectableClassLists) { + Assert.ok( + false, + "Impression test didn't complete successfully, stopping telemetry test" + ); + return; + } + + info( + "Got classLists of actual selectable elements in the row: " + + JSON.stringify(selectableClassLists) + ); + + let allMatchedExpectedClasses = new Set(); + + // For each actual selectable element in the row, do a selectable test by + // picking the element and checking telemetry. + for (let classList of selectableClassLists) { + info( + "Setting up selectable test for actual element with classList " + + JSON.stringify(classList) + ); + + // Each of the actual selectable elements should match exactly one of the + // test's expected selectable classes. + // + // * If an element doesn't match any expected class, then the test does not + // account for that element, which is an error in the test. + // * If an element matches more than one expected class, then the expected + // class is not specific enough, which is also an error in the test. + + // Collect all the expected classes that match the actual element. + let matchingExpectedClasses = Object.keys(selectables).filter(className => + classList.includes(className) + ); + + if (!matchingExpectedClasses.length) { + Assert.ok( + false, + "Actual selectable element doesn't match any expected classes. The element's classList is " + + JSON.stringify(classList) + ); + continue; + } + if (matchingExpectedClasses.length > 1) { + Assert.ok( + false, + "Actual selectable element matches multiple expected classes. The element's classList is " + + JSON.stringify(classList) + ); + continue; + } + + let className = matchingExpectedClasses[0]; + allMatchedExpectedClasses.add(className); + + await doSelectableTest({ + suggestion, + providerName, + showSuggestion, + index, + className, + expected: selectables[className], + }); + + if (teardown) { + info("Calling teardown"); + await teardown(); + info("Finished teardown"); + } + } + + // Finally, if an expected class doesn't match any actual element, then the + // test expects an element to be picked that either isn't present or isn't + // selectable, which is an error in the test. + Assert.deepEqual( + Object.keys(selectables).filter( + className => !allMatchedExpectedClasses.has(className) + ), + [], + "There should be no expected classes that didn't match actual selectable elements" + ); +} + +/** + * Helper for `doTelemetryTest()` that does an impression-only test. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {object} options.expected + * An object describing the expected impression-only telemetry. It must have + * the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {object} ping + * The expected recorded custom telemetry ping. If no ping is expected, + * leave this undefined or pass null. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + * @returns {Array} + * The `classList` values of all the selectable elements in the suggestion's + * row. Each item in this array is a selectable element's `classList` that has + * been converted to an array of strings. + */ +async function doImpressionOnlyTest({ + index, + suggestion, + providerName, + expected, + showSuggestion, +}) { + info("Starting impression-only test"); + + Services.telemetry.clearEvents(); + let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy(); + + info("Showing suggestion"); + await showSuggestion(); + + // Get the suggestion row. + let row = await validateSuggestionRow(index, suggestion, providerName); + if (!row) { + Assert.ok( + false, + "Couldn't get suggestion row, stopping impression-only test" + ); + await spyCleanup(); + return null; + } + + // We need to get a different selectable row so we can pick it to trigger + // impression-only telemetry. For simplicity we'll look for a row that will + // load a URL when picked. We'll also verify no other rows are from the + // expected provider. + let otherRow; + let rowCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < rowCount; i++) { + if (i != index) { + let r = await UrlbarTestUtils.waitForAutocompleteResultAt(window, i); + Assert.notEqual( + r.result.providerName, + providerName, + "No other row should be from expected provider: index = " + i + ); + if ( + !otherRow && + (r.result.payload.url || + (r.result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + (r.result.payload.query || r.result.payload.suggestion))) && + r.hasAttribute("row-selectable") + ) { + otherRow = r; + } + } + } + if (!otherRow) { + Assert.ok( + false, + "Couldn't get a different selectable row with a URL, stopping impression-only test" + ); + await spyCleanup(); + return null; + } + + // Collect the `classList` values for all selectable elements in the row. + let selectableClassLists = []; + let selectables = row.querySelectorAll(":is([selectable], [role=button])"); + for (let element of selectables) { + selectableClassLists.push([...element.classList]); + } + + // Pick the different row. Assumptions: + // * The middle of the row is selectable + // * Picking the row will load a page + info("Clicking different row and waiting for view to close"); + let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await UrlbarTestUtils.promisePopupClose(window, () => + EventUtils.synthesizeMouseAtCenter(otherRow, {}) + ); + + info("Waiting for page to load after clicking different row"); + await loadPromise; + + // Check telemetry. + info("Checking scalars. Expected: " + JSON.stringify(expected.scalars)); + QuickSuggestTestUtils.assertScalars(expected.scalars); + + info("Checking events. Expected: " + JSON.stringify([expected.event])); + QuickSuggestTestUtils.assertEvents([expected.event]); + + let expectedPings = expected.ping ? [expected.ping] : []; + info("Checking pings. Expected: " + JSON.stringify(expectedPings)); + QuickSuggestTestUtils.assertPings(spy, expectedPings); + + // Clean up. + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + await spyCleanup(); + + info("Finished impression-only test"); + + return selectableClassLists; +} + +/** + * Helper for `doTelemetryTest()` that picks a selectable element in a + * suggestion's row and checks telemetry. + * + * @param {object} options + * Options + * @param {number} options.index + * The expected index of the suggestion in the results list. + * @param {object} options.suggestion + * The suggestion being tested. + * @param {string} options.providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @param {string} options.className + * An HTML class name that should uniquely identify the selectable element + * within its row. + * @param {object} options.expected + * An object describing the telemetry that's expected to be recorded when the + * selectable element is picked. It must have the following properties: + * {object} scalars + * An object that maps expected scalar names to values. + * {object} event + * The expected recorded event. + * {Array} pings + * A list of expected recorded custom telemetry pings. If no pings are + * expected, leave this undefined or pass an empty array. + * @param {Function} options.showSuggestion + * This function should open the view and show the suggestion. + */ +async function doSelectableTest({ + index, + suggestion, + providerName, + className, + expected, + showSuggestion, +}) { + info("Starting selectable test: " + JSON.stringify({ className })); + + Services.telemetry.clearEvents(); + let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy(); + + info("Showing suggestion"); + await showSuggestion(); + + let row = await validateSuggestionRow(index, suggestion, providerName); + if (!row) { + Assert.ok(false, "Couldn't get suggestion row, stopping selectable test"); + await spyCleanup(); + return; + } + + let element = row.querySelector("." + className); + Assert.ok(element, "Sanity check: Target selectable element should exist"); + + let loadPromise; + if (className == "urlbarView-row-inner") { + // We assume clicking the row-inner will cause a page to load in the current + // browser. + loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + } else if (className == "urlbarView-button-help") { + loadPromise = BrowserTestUtils.waitForNewTab(gBrowser); + } + + info("Clicking element: " + className); + EventUtils.synthesizeMouseAtCenter(element, {}); + + if (loadPromise) { + info("Waiting for load"); + await loadPromise; + await TestUtils.waitForTick(); + if (className == "urlbarView-button-help") { + info("Closing help tab"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + } + } + + info("Checking scalars. Expected: " + JSON.stringify(expected.scalars)); + QuickSuggestTestUtils.assertScalars(expected.scalars); + + info("Checking events. Expected: " + JSON.stringify([expected.event])); + QuickSuggestTestUtils.assertEvents([expected.event]); + + let expectedPings = expected.pings ?? []; + info("Checking pings. Expected: " + JSON.stringify(expectedPings)); + QuickSuggestTestUtils.assertPings(spy, expectedPings); + + if (className == "urlbarView-button-block") { + await QuickSuggest.blockedSuggestions.clear(); + } + await PlacesUtils.history.clear(); + await spyCleanup(); + + info("Finished selectable test: " + JSON.stringify({ className })); +} + +/** + * Gets a row in the view, which is assumed to be open, and asserts that it's a + * particular quick suggest row. If it is, the row is returned. If it's not, + * null is returned. + * + * @param {number} index + * The expected index of the quick suggest row. + * @param {object} suggestion + * The expected suggestion. + * @param {string} providerName + * The name of the provider that is expected to create the UrlbarResult for + * the suggestion. + * @returns {Element} + * If the row is the expected suggestion, the row element is returned. + * Otherwise null is returned. + */ +async function validateSuggestionRow(index, suggestion, providerName) { + let rowCount = UrlbarTestUtils.getResultCount(window); + Assert.less( + index, + rowCount, + "Expected suggestion row index should be < row count" + ); + if (rowCount <= index) { + return null; + } + + let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, index); + Assert.equal( + row.result.providerName, + providerName, + "Expected suggestion row should be from expected provider" + ); + Assert.equal( + row.result.payload.url, + suggestion.url, + "The suggestion row should represent the expected suggestion" + ); + if ( + row.result.providerName != providerName || + row.result.payload.url != suggestion.url + ) { + return null; + } + + return row; +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..145392fcf2 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + let timeout = parseInt(params.timeout); + if (timeout) { + // Write the response after a timeout. + resp.processAsync(); + gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + gTimer.init( + () => { + writeResponse(params, resp); + resp.finish(); + }, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + return; + } + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echo back the search string with "foo" and "bar" appended. + let suffixes = ["foo", "bar"]; + if (params.count) { + // Add more suffixes. + let serial = 0; + while (suffixes.length < params.count) { + suffixes.push(++serial); + } + } + let data = [params.query, suffixes.map(s => params.query + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..142c91849c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml new file mode 100644 index 0000000000..67303f19ac --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml @@ -0,0 +1,14 @@ +<?xml version="1.0"?> + +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Sample sub-dialog"> +<dialog id="subDialog"> + <description id="desc">A sample sub-dialog for testing</description> +</dialog> +</window> diff --git a/browser/components/urlbar/tests/quicksuggest/unit/head.js b/browser/components/urlbar/tests/quicksuggest/unit/head.js new file mode 100644 index 0000000000..39f920fce5 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../unit/head.js */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +add_setup(async function setUpQuickSuggestXpcshellTest() { + // Initializing TelemetryEnvironment in an xpcshell environment requires + // jumping through a bunch of hoops. Suggest's use of TelemetryEnvironment is + // tested in browser tests, and there's no other necessary reason to wait for + // TelemetryEnvironment initialization in xpcshell tests, so just skip it. + UrlbarPrefs._testSkipTelemetryEnvironmentInit = true; +}); + +/** + * Tests quick suggest prefs migrations. + * + * @param {object} options + * The options object. + * @param {object} options.testOverrides + * An object that modifies how migration is performed. It has the following + * properties, and all are optional: + * + * {number} migrationVersion + * Migration will stop at this version, so for example you can test + * migration only up to version 1 even when the current actual version is + * larger than 1. + * {object} defaultPrefs + * An object that maps pref names (relative to `browser.urlbar`) to + * default-branch values. These should be the default prefs for the given + * `migrationVersion` and will be set as defaults before migration occurs. + * + * @param {string} options.scenario + * The scenario to set at the time migration occurs. + * @param {object} options.expectedPrefs + * The expected prefs after migration: `{ defaultBranch, userBranch }` + * Pref names should be relative to `browser.urlbar`. + * @param {object} [options.initialUserBranch] + * Prefs to set on the user branch before migration ocurs. Use these to + * simulate user actions like disabling prefs or opting in or out of the + * online modal. Pref names should be relative to `browser.urlbar`. + */ +async function doMigrateTest({ + testOverrides, + scenario, + expectedPrefs, + initialUserBranch = {}, +}) { + info( + "Testing migration: " + + JSON.stringify({ + testOverrides, + initialUserBranch, + scenario, + expectedPrefs, + }) + ); + + function setPref(branch, name, value) { + switch (typeof value) { + case "boolean": + branch.setBoolPref(name, value); + break; + case "number": + branch.setIntPref(name, value); + break; + case "string": + branch.setCharPref(name, value); + break; + default: + Assert.ok( + false, + `Pref type not handled for setPref: ${name} = ${value}` + ); + break; + } + } + + function getPref(branch, name) { + let type = typeof UrlbarPrefs.get(name); + switch (type) { + case "boolean": + return branch.getBoolPref(name); + case "number": + return branch.getIntPref(name); + case "string": + return branch.getCharPref(name); + default: + Assert.ok(false, `Pref type not handled for getPref: ${name} ${type}`); + break; + } + return null; + } + + let defaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); + let userBranch = Services.prefs.getBranch("browser.urlbar."); + + // Set initial prefs. `initialDefaultBranch` are firefox.js values, i.e., + // defaults immediately after startup and before any scenario update and + // migration happens. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let initialDefaultBranch = { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }; + for (let name of Object.keys(initialDefaultBranch)) { + userBranch.clearUserPref(name); + } + for (let [branch, prefs] of [ + [defaultBranch, initialDefaultBranch], + [userBranch, initialUserBranch], + ]) { + for (let [name, value] of Object.entries(prefs)) { + if (value !== undefined) { + setPref(branch, name, value); + } + } + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + + // Update the scenario and check prefs twice. The first time the migration + // should happen, and the second time the migration should not happen and + // all the prefs should stay the same. + for (let i = 0; i < 2; i++) { + info(`Calling updateFirefoxSuggestScenario, i=${i}`); + + // Do the scenario update and set `isStartup` to simulate startup. + await UrlbarPrefs.updateFirefoxSuggestScenario({ + ...testOverrides, + scenario, + isStartup: true, + }); + + // Check expected pref values. Store expected effective values as we go so + // we can check them afterward. For a given pref, the expected effective + // value is the user value, or if there's not a user value, the default + // value. + let expectedEffectivePrefs = {}; + let { + defaultBranch: expectedDefaultBranch, + userBranch: expectedUserBranch, + } = expectedPrefs; + expectedDefaultBranch = expectedDefaultBranch || {}; + expectedUserBranch = expectedUserBranch || {}; + for (let [branch, prefs, branchType] of [ + [defaultBranch, expectedDefaultBranch, "default"], + [userBranch, expectedUserBranch, "user"], + ]) { + let entries = Object.entries(prefs); + if (!entries.length) { + continue; + } + + info( + `Checking expected prefs on ${branchType} branch after updating scenario` + ); + for (let [name, value] of entries) { + expectedEffectivePrefs[name] = value; + if (branch == userBranch) { + Assert.ok( + userBranch.prefHasUserValue(name), + `Pref ${name} is on user branch` + ); + } + Assert.equal( + getPref(branch, name), + value, + `Pref ${name} value on ${branchType} branch` + ); + } + } + + info( + `Making sure prefs on the default branch without expected user-branch values are not on the user branch` + ); + for (let name of Object.keys(initialDefaultBranch)) { + if (!expectedUserBranch.hasOwnProperty(name)) { + Assert.ok( + !userBranch.prefHasUserValue(name), + `Pref ${name} is not on user branch` + ); + } + } + + info(`Checking expected effective prefs`); + for (let [name, value] of Object.entries(expectedEffectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value` + ); + } + + let currentVersion = + testOverrides?.migrationVersion === undefined + ? UrlbarPrefs.FIREFOX_SUGGEST_MIGRATION_VERSION + : testOverrides.migrationVersion; + Assert.equal( + UrlbarPrefs.get("quicksuggest.migrationVersion"), + currentVersion, + "quicksuggest.migrationVersion is correct after migration" + ); + } + + // Clean up. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let userBranchNames = [ + ...Object.keys(initialUserBranch), + ...Object.keys(expectedPrefs.userBranch || {}), + ]; + for (let name of userBranchNames) { + userBranch.clearUserPref(name); + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js new file mode 100644 index 0000000000..cd45cb11a7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js @@ -0,0 +1,647 @@ +/* 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(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js new file mode 100644 index 0000000000..70e970af8a --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js @@ -0,0 +1,402 @@ +/* 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 sessions. + +"use strict"; + +const { MerinoClient } = ChromeUtils.importESModule( + "resource:///modules/MerinoClient.sys.mjs" +); + +const { SEARCH_PARAMS } = MerinoClient; + +let gClient; + +add_task(async function init() { + gClient = new MerinoClient(); + await MerinoTestUtils.server.start(); +}); + +// In a single session, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleSession() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Different sessions should use different session IDs and the sequence number +// should be reset. +add_task(async function manySessions() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + gClient.resetSession(); + } +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Wait for the mock Merino server to receive the request +// 3. Start a second fetch before the client receives the response +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_wait() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Wait until the first request is received before starting the second + // fetch, which will cancel the first. The response doesn't need to be + // delayed, so remove it to make the test run faster. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Immediately start a second fetch +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_immediate() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = + 100 * UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Immediately do a second fetch that cancels the first. The response + // doesn't need to be delayed, so remove it to make the test run faster. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch, but the + // first won't have reached the server since it was immediately canceled. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When a network error occurs, the sequence number should still be incremented. +add_task(async function networkError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with a network error. + let query1 = "search" + i; + await MerinoTestUtils.server.withNetworkError(async () => { + await gClient.fetch({ query: query1 }); + }); + + Assert.equal( + gClient.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + + // Do another fetch that successfully finishes. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request completed successfully" + ); + + // Only the second request should have been received but the sequence number + // should have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the server returns a response with an HTTP error, the sequence number +// should be incremented. +add_task(async function httpError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with an HTTP error. + MerinoTestUtils.server.response.status = 500; + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "http_error", + "The last request failed with a network error" + ); + + // Do another fetch that successfully finishes. + MerinoTestUtils.server.response.status = 200; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + // Both requests should have been received and the sequence number should + // have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + + MerinoTestUtils.server.reset(); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response but later receives it and no +// other fetch happens in the meantime, the sequence number should be +// incremented. +add_task(async function clientTimeout_wait() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let responsePromise = gClient.waitForNextResponse(); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Wait for the client to receive the response. + await responsePromise; + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response and a second fetch starts +// before the response is received, the first fetch should be canceled but the +// sequence number should still be incremented. +add_task(async function clientTimeout_canceled() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the session times out, the next fetch should use a new session ID and +// the sequence number should be reset. +add_task(async function sessionTimeout() { + // Set the session timeout to something reasonable to test. + let originalTimeoutMs = gClient.sessionTimeoutMs; + gClient.sessionTimeoutMs = 500; + + // Do a fetch. + let query1 = "search"; + await gClient.fetch({ query: query1 }); + + // Wait for the session to time out. + await gClient.waitForNextSessionReset(); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after session timeout" + ); + Assert.strictEqual( + gClient.sequenceNumber, + 0, + "sequenceNumber is zero after session timeout" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after session timeout" + ); + + // Do another fetch. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The second request's sequence number should be zero due to the session + // timeout. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + Assert.ok( + gClient.sessionID, + "sessionID is non-null after first request in a new session" + ); + Assert.equal( + gClient.sequenceNumber, + 1, + "sequenceNumber is one after first request in a new session" + ); + Assert.ok( + gClient._test_sessionTimer, + "sessionTimer is non-null after first request in a new session" + ); + + gClient.sessionTimeoutMs = originalTimeoutMs; + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js new file mode 100644 index 0000000000..00e9820fab --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js @@ -0,0 +1,1341 @@ +/* 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/. */ + +// Basic tests for the quick suggest provider using the remote settings source. +// See also test_quicksuggest_merino.js. + +"use strict"; + +const TELEMETRY_REMOTE_SETTINGS_LATENCY = + "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +const SPONSORED_SEARCH_STRING = "frab"; +const NONSPONSORED_SEARCH_STRING = "nonspon"; + +const HTTP_SEARCH_STRING = "http prefix"; +const HTTPS_SEARCH_STRING = "https prefix"; +const PREFIX_SUGGESTIONS_STRIPPED_URL = "example.com/prefix-test"; + +const { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = QuickSuggest; +const TIMESTAMP_SEARCH_STRING = "timestamp"; +const TIMESTAMP_SUGGESTION_URL = `http://example.com/timestamp-${TIMESTAMP_TEMPLATE}`; +const TIMESTAMP_SUGGESTION_CLICK_URL = `http://click.reporting.test.com/timestamp-${TIMESTAMP_TEMPLATE}-foo`; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: [SPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: "http://test.com/?q=nonsponsored", + title: "Non-Sponsored", + keywords: [NONSPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + }, + { + id: 3, + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "http suggestion", + keywords: [HTTP_SEARCH_STRING], + click_url: "http://click.reporting.test.com/prefix", + impression_url: "http://impression.reporting.test.com/prefix", + advertiser: "TestAdvertiserPrefix", + iab_category: "22 - Shopping", + }, + { + id: 4, + url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "https suggestion", + keywords: [HTTPS_SEARCH_STRING], + click_url: "http://click.reporting.test.com/prefix", + impression_url: "http://impression.reporting.test.com/prefix", + advertiser: "TestAdvertiserPrefix", + iab_category: "22 - Shopping", + }, + { + id: 5, + url: TIMESTAMP_SUGGESTION_URL, + title: "Timestamp suggestion", + keywords: [TIMESTAMP_SEARCH_STRING], + click_url: TIMESTAMP_SUGGESTION_CLICK_URL, + impression_url: "http://impression.reporting.test.com/timestamp", + advertiser: "TestAdvertiserTimestamp", + iab_category: "22 - Shopping", + }, +]; + +const EXPECTED_SPONSORED_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "frab", + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +const EXPECTED_NONSPONSORED_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + qsSuggestion: "nonspon", + title: "Non-Sponsored", + url: "http://test.com/?q=nonsponsored", + originalUrl: "http://test.com/?q=nonsponsored", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/nonsponsored", + sponsoredClickUrl: "http://click.reporting.test.com/nonsponsored", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiserNonSponsored", + sponsoredIabCategory: "5 - Education", + isSponsored: false, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/?q=nonsponsored", + source: "remote-settings", + }, +}; + +const EXPECTED_HTTP_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: HTTP_SEARCH_STRING, + title: "http suggestion", + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + originalUrl: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/prefix", + sponsoredClickUrl: "http://click.reporting.test.com/prefix", + sponsoredBlockId: 3, + sponsoredAdvertiser: "TestAdvertiserPrefix", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + source: "remote-settings", + }, +}; + +const EXPECTED_HTTPS_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: HTTPS_SEARCH_STRING, + title: "https suggestion", + url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + originalUrl: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/prefix", + sponsoredClickUrl: "http://click.reporting.test.com/prefix", + sponsoredBlockId: 4, + sponsoredAdvertiser: "TestAdvertiserPrefix", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: PREFIX_SUGGESTIONS_STRIPPED_URL, + source: "remote-settings", + }, +}; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", false); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + UrlbarPrefs.set("merino.enabled", false); + + // Install a default test engine. + let engine = await addTestSuggestionsEngine(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + const testDataTypeResults = [ + Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { title: "test-data-type" }), + ]; + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + { + type: "test-data-type", + attachment: testDataTypeResults, + }, + ], + }); +}); + +// Tests with only non-sponsored suggestions enabled with a matching search +// string. +add_task(async function nonsponsoredOnly_match() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NONSPONSORED_RESULT], + }); +}); + +// Tests with only non-sponsored suggestions enabled with a non-matching search +// string. +add_task(async function nonsponsoredOnly_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with only sponsored suggestions enabled with a matching search string. +add_task(async function sponsoredOnly_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// Tests with only sponsored suggestions enabled with a non-matching search +// string. +add_task(async function sponsoredOnly_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the sponsored suggestion. +add_task(async function both_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the non-sponsored suggestion. +add_task(async function both_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NONSPONSORED_RESULT], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that doesn't match either suggestion. +add_task(async function both_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext("this doesn't match anything", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the sponsored suggestion. +add_task(async function neither_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the non-sponsored suggestion. +add_task(async function neither_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Search string matching should be case insensitive and ignore leading spaces. +add_task(async function caseInsensitiveAndLeadingSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(" " + SPONSORED_SEARCH_STRING.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// The provider should not be active for search strings that are empty or +// contain only spaces. +add_task(async function emptySearchStringsAndSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let searchStrings = ["", " ", " ", " "]; + for (let str of searchStrings) { + let msg = JSON.stringify(str) + ` (length = ${str.length})`; + info("Testing search string: " + msg); + + let context = createContext(str, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + Assert.ok( + !UrlbarProviderQuickSuggest.isActive(context), + "Provider should not be active for search string: " + msg + ); + } +}); + +// Results should be returned even when `browser.search.suggest.enabled` is +// false. +add_task(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); +}); + +// Results should be returned even when `browser.urlbar.suggest.searches` is +// false. +add_task(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.searches", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); + + UrlbarPrefs.clear("suggest.searches"); +}); + +// Neither sponsored nor non-sponsored results should appear in private contexts +// even when suggestions in private windows are enabled. +add_task(async function privateContext() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + for (let privateSuggestionsEnabled of [true, false]) { + UrlbarPrefs.set( + "browser.search.suggest.enabled.private", + privateSuggestionsEnabled + ); + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: true, + }); + await check_results({ + context, + matches: [], + }); + } + + UrlbarPrefs.clear("browser.search.suggest.enabled.private"); +}); + +// When search suggestions come before general results and the only general +// result is a quick suggest result, it should come last. +add_task(async function suggestionsBeforeGeneral_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + EXPECTED_SPONSORED_RESULT, + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When search suggestions come before general results and there are other +// general results besides quick suggest, the quick suggest result should come +// last. +add_task(async function suggestionsBeforeGeneral_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + EXPECTED_SPONSORED_RESULT, + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +// When general results come before search suggestions and the only general +// result is a quick suggest result, it should come before suggestions. +add_task(async function generalBeforeSuggestions_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + EXPECTED_SPONSORED_RESULT, + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When general results come before search suggestions and there are other +// general results besides quick suggest, the quick suggest result should be the +// last general result. +add_task(async function generalBeforeSuggestions_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + EXPECTED_SPONSORED_RESULT, + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +add_task(async function dedupeAgainstURL_samePrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTP_RESULT, + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_task(async function dedupeAgainstURL_higherPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTPS_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTPS_RESULT, + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_task(async function dedupeAgainstURL_lowerPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTP_RESULT, + otherPrefix: "https://", + expectOther: true, + }); +}); + +/** + * Tests how the muxer dedupes URL results against quick suggest results. + * Depending on prefix rank, quick suggest results should be preferred over + * other URL results with the same stripped URL: Other results should be + * discarded when their prefix rank is lower than the prefix rank of the quick + * suggest. They should not be discarded when their prefix rank is higher, and + * in that case both results should be included. + * + * This function adds a visit to the URL formed by the given `otherPrefix` and + * `PREFIX_SUGGESTIONS_STRIPPED_URL`. The visit's title will be set to the given + * `searchString` so that both the visit and the quick suggest will match it. + * + * @param {object} options + * Options object. + * @param {string} options.searchString + * The search string that should trigger one of the mock prefix-test quick + * suggest results. + * @param {object} options.expectedQuickSuggestResult + * The expected quick suggest result. + * @param {string} options.otherPrefix + * The visit will be created with a URL with this prefix, e.g., "http://". + * @param {boolean} options.expectOther + * Whether the visit result should appear in the final results. + */ +async function doDedupeAgainstURLTest({ + searchString, + expectedQuickSuggestResult, + otherPrefix, + expectOther, +}) { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match our query below. + let otherURL = otherPrefix + PREFIX_SUGGESTIONS_STRIPPED_URL; + await PlacesTestUtils.addVisits({ uri: otherURL, title: searchString }); + + // First, do a search with quick suggest disabled to make sure the search + // string matches the visit. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }), + ], + }); + + // Now do another search with quick suggest enabled. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + context = createContext(searchString, { isPrivate: false }); + + let expectedResults = [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + ]; + if (expectOther) { + expectedResults.push( + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }) + ); + } + expectedResults.push(expectedQuickSuggestResult); + + info("Doing second query"); + await check_results({ context, matches: expectedResults }); + + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +} + +// Tests the remote settings latency histogram. +add_task(async function latencyTelemetry() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let histogram = Services.telemetry.getHistogramById( + TELEMETRY_REMOTE_SETTINGS_LATENCY + ); + histogram.clear(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); + + // In the latency histogram, there should be a single value across all + // buckets. + Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Latency histogram updated after search" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_REMOTE_SETTINGS_LATENCY, context), + "Stopwatch not running after search" + ); +}); + +// Tests setup and teardown of the remote settings client depending on whether +// quick suggest is enabled. +add_task(async function setupAndTeardown() { + // Disable the suggest prefs so the settings client starts out torn down. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest prefs" + ); + + // Setting one of the suggest prefs should cause the client to be set up. We + // assume all previous tasks left `quicksuggest.enabled` true (from the init + // task). + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client remains non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client remains non-null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling quicksuggest.enabled" + ); + + // Leave the prefs in the same state as when the task started. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.set("quicksuggest.enabled", true); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client remains null at end of task" + ); +}); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_task(async function timestamps() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); +}); + +// Real quick suggest URLs include a timestamp template that +// UrlbarProviderQuickSuggest fills in when it fetches suggestions. When the +// user picks a quick suggest, its URL with its particular timestamp is added to +// history. If the user triggers the quick suggest again later, its new +// timestamp may be different from the one in the user's history. In that case, +// the two URLs should be treated as dupes and only the quick suggest should be +// shown, not the URL from history. +add_task(async function dedupeAgainstURL_timestamps() { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match the query below and dupe the quick suggest. + let dupeURL = TIMESTAMP_SUGGESTION_URL.replace( + TIMESTAMP_TEMPLATE, + "2013051113" + ); + + // Add other visits that will match the query and almost dupe the quick + // suggest but not quite because they have invalid timestamps. + let badTimestamps = [ + // not numeric digits + "x".repeat(TIMESTAMP_LENGTH), + // too few digits + "5".repeat(TIMESTAMP_LENGTH - 1), + // empty string, too few digits + "", + ]; + let badTimestampURLs = badTimestamps.map(str => + TIMESTAMP_SUGGESTION_URL.replace(TIMESTAMP_TEMPLATE, str) + ); + + await PlacesTestUtils.addVisits( + [dupeURL, ...badTimestampURLs].map(uri => ({ + uri, + title: TIMESTAMP_SEARCH_STRING, + })) + ); + + // First, do a search with quick suggest disabled to make sure the search + // string matches all the other URLs. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + let expectedHeuristic = makeSearchResult(context, { + heuristic: true, + query: TIMESTAMP_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }); + let expectedDupeResult = makeVisitResult(context, { + uri: dupeURL, + title: TIMESTAMP_SEARCH_STRING, + }); + let expectedBadTimestampResults = [...badTimestampURLs].reverse().map(uri => + makeVisitResult(context, { + uri, + title: TIMESTAMP_SEARCH_STRING, + }) + ); + + await check_results({ + context, + matches: [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedDupeResult, + ], + }); + + // Now do another search with quick suggest enabled. + info("Doing second query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + // The expected quick suggest result without the timestamp-related payload + // properties. + let expectedQuickSuggest = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + originalUrl: TIMESTAMP_SUGGESTION_URL, + qsSuggestion: TIMESTAMP_SEARCH_STRING, + title: "Timestamp suggestion", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/timestamp", + sponsoredBlockId: 5, + sponsoredAdvertiser: "TestAdvertiserTimestamp", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }; + + let expectedResults = [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedQuickSuggest, + ]; + + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: false, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + await controller.startQuery(context); + info("Actual results: " + JSON.stringify(context.results)); + + Assert.equal( + context.results.length, + expectedResults.length, + "Found the expected number of results" + ); + + function getPayload(result, keysToIgnore = []) { + let payload = {}; + for (let [key, value] of Object.entries(result.payload)) { + if (value !== undefined && !keysToIgnore.includes(key)) { + payload[key] = value; + } + } + return payload; + } + + // Check actual vs. expected result properties. + for (let i = 0; i < expectedResults.length; i++) { + let actual = context.results[i]; + let expected = expectedResults[i]; + info( + `Comparing results at index ${i}:` + + " actual=" + + JSON.stringify(actual) + + " expected=" + + JSON.stringify(expected) + ); + Assert.equal( + actual.type, + expected.type, + `result.type at result index ${i}` + ); + Assert.equal( + actual.source, + expected.source, + `result.source at result index ${i}` + ); + Assert.equal( + actual.heuristic, + expected.heuristic, + `result.heuristic at result index ${i}` + ); + + // Check payloads except for the last result, which should be the quick + // suggest. + if (i != expectedResults.length - 1) { + Assert.deepEqual( + getPayload(context.results[i]), + getPayload(expectedResults[i]), + "Payload at index " + i + ); + } + } + + // Check the quick suggest's payload excluding the timestamp-related + // properties. + let actualQuickSuggest = context.results[context.results.length - 1]; + let timestampKeys = [ + "displayUrl", + "sponsoredClickUrl", + "url", + "urlTimestampIndex", + ]; + Assert.deepEqual( + getPayload(actualQuickSuggest, timestampKeys), + getPayload(expectedQuickSuggest, timestampKeys), + "Quick suggest payload excluding timestamp-related keys" + ); + + // Now check the timestamps in the payload. + QuickSuggestTestUtils.assertTimestampsReplaced(actualQuickSuggest, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); + + // Clean up. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +}); + +// Tests the API for blocking suggestions and the backing pref. +add_task(async function blockedSuggestionsAPI() { + // Start with no blocked suggestions. + await QuickSuggest.blockedSuggestions.clear(); + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is empty" + ); + Assert.equal( + UrlbarPrefs.get("quicksuggest.blockedDigests"), + "", + "quicksuggest.blockedDigests is an empty string" + ); + + // Make some URLs. + let urls = []; + for (let i = 0; i < 3; i++) { + urls.push("http://example.com/" + i); + } + + // Block each URL in turn and make sure previously blocked URLs are still + // blocked and the remaining URLs are not blocked. + for (let i = 0; i < urls.length; i++) { + await QuickSuggest.blockedSuggestions.add(urls[i]); + for (let j = 0; j < urls.length; j++) { + Assert.equal( + await QuickSuggest.blockedSuggestions.has(urls[j]), + j <= i, + `Suggestion at index ${j} is blocked or not as expected` + ); + } + } + + // Make sure all URLs are blocked for good measure. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + + // Check `blockedSuggestions._test_digests` and `quicksuggest.blockedDigests`. + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + let array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Write some junk to `quicksuggest.blockedDigests`. + // `blockedSuggestions._test_digests` should not be changed and all previously + // blocked URLs should remain blocked. + UrlbarPrefs.set("quicksuggest.blockedDigests", "not a json array"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion remains blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests still has correct size" + ); + + // Block a new URL. All URLs should remain blocked and the pref should be + // updated. + let newURL = "http://example.com/new-block"; + await QuickSuggest.blockedSuggestions.add(newURL); + urls.push(newURL); + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Add a new URL digest directly to the JSON'ed array in the pref. + newURL = "http://example.com/direct-to-pref"; + urls.push(newURL); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + array.push(await QuickSuggest.blockedSuggestions._test_getDigest(newURL)); + UrlbarPrefs.set("quicksuggest.blockedDigests", JSON.stringify(array)); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + // All URLs should remain blocked and the new URL should be blocked. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + + // Clear the pref. All URLs should be unblocked. + UrlbarPrefs.clear("quicksuggest.blockedDigests"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); + + // Block all the URLs again and test `blockedSuggestions.clear()`. + for (let url of urls) { + await QuickSuggest.blockedSuggestions.add(url); + } + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + await QuickSuggest.blockedSuggestions.clear(); + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); +}); + +// Test whether the blocking for remote settings results works. +add_task(async function block() { + for (const result of REMOTE_SETTINGS_RESULTS) { + await QuickSuggest.blockedSuggestions.add(result.url); + } + + for (const result of REMOTE_SETTINGS_RESULTS) { + const context = createContext(result.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + } + + await QuickSuggest.blockedSuggestions.clear(); +}); + +// Makes sure remote settings data is fetched using the correct `type` based on +// the value of the `quickSuggestRemoteSettingsDataType` Nimbus variable. +add_task(async function remoteSettingsDataType() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + for (let dataType of [undefined, "test-data-type"]) { + // Set up a mock Nimbus rollout with the data type. + let value = {}; + if (dataType) { + value.quickSuggestRemoteSettingsDataType = dataType; + } + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(value); + + // Make the result for test data type. + let expected = EXPECTED_SPONSORED_RESULT; + if (dataType) { + expected = JSON.parse(JSON.stringify(expected)); + expected.payload.title = dataType; + } + + // Re-enable to trigger sync from remote settings. + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", false); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expected], + }); + + await cleanUpNimbus(); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js new file mode 100644 index 0000000000..d667fe35b7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js @@ -0,0 +1,728 @@ +/* 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/. */ + +// Tests addon quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", +}); + +const MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "icon", + url: "url", + title: "title", + description: "description", + is_top_pick: true, + custom_details: { + amo: { + rating: "5", + number_of_ratings: "1234567", + guid: "test@addon", + }, + }, + }, +]; + +const REMOTE_SETTINGS_RESULTS = [ + { + type: "amo-suggestions", + attachment: [ + { + url: "https://example.com/first-addon", + guid: "first@addon", + icon: "https://example.com/first-addon.svg", + title: "First Addon", + rating: "4.7", + keywords: ["first", "1st", "two words", "a b c"], + description: "Description for the First Addon", + number_of_ratings: 1256, + is_top_pick: true, + }, + { + url: "https://example.com/second-addon", + guid: "second@addon", + icon: "https://example.com/second-addon.svg", + title: "Second Addon", + rating: "1.7", + keywords: ["second", "2nd"], + description: "Description for the Second Addon", + number_of_ratings: 256, + is_top_pick: false, + }, + { + url: "https://example.com/third-addon", + guid: "third@addon", + icon: "https://example.com/third-addon.svg", + title: "Third Addon", + rating: "3.7", + keywords: ["third", "3rd"], + description: "Description for the Third Addon", + number_of_ratings: 3, + }, + ], + }, +]; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("addons.featureGate", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: REMOTE_SETTINGS_RESULTS, + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// When non-sponsored suggestions are disabled, addon suggestions should be +// disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Addon suggestions are non-sponsored, so + // doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +// When addon suggestions specific preference is disabled, addon suggestions +// should not be added. +add_task(async function addonSuggestionsSpecificPrefDisabled() { + const prefs = ["suggest.addons", "addons.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + } +}); + +// Check wheather the addon suggestions will be shown by the setup of Nimbus +// variable. +add_task(async function nimbus() { + // Disable the fature gate. + UrlbarPrefs.set("addons.featureGate", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: true, + }); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("addons.featureGate", true); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: false, + }); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.set("addons.featureGate", true); +}); + +add_task(async function hideIfAlreadyInstalled() { + // Show suggestion. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + + // Install an addon for the suggestion. + const xpi = ExtensionTestCommon.generateXPI({ + manifest: { + browser_specific_settings: { + gecko: { id: "test@addon" }, + }, + }, + }); + const addon = await AddonManager.installTemporaryAddon(xpi); + + // Show suggestion for the addon installed. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + await addon.uninstall(); + xpi.remove(false); +}); + +add_task(async function remoteSettings() { + const testCases = [ + { + input: "f", + expected: null, + }, + { + input: "fi", + expected: null, + }, + { + input: "fir", + expected: null, + }, + { + input: "firs", + expected: null, + }, + { + input: "first", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "1st", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "t", + expected: null, + }, + { + input: "tw", + expected: null, + }, + { + input: "two", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two w", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two wo", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two wor", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two word", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two words", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b c", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "second", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + isTopPick: false, + }), + }, + { + input: "2nd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + isTopPick: false, + }), + }, + { + input: "third", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "3rd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + isTopPick: true, + }), + }, + ]; + + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + for (const { input, expected } of testCases) { + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expected ? [expected] : [], + }); + } + + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +}); + +add_task(async function merinoIsTopPick() { + const suggestion = JSON.parse(JSON.stringify(MERINO_SUGGESTIONS[0])); + + // is_top_pick is specified as false. + suggestion.is_top_pick = false; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + isTopPick: false, + }), + ], + }); + + // is_top_pick is undefined. + delete suggestion.is_top_pick; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + isTopPick: true, + }), + ], + }); +}); + +// Tests "show less frequently" with the cap set in remote settings. +add_task(async function showLessFrequently_rs() { + await doShowLessFrequentlyTest({ + rs: { + show_less_frequently_cap: 3, + }, + tests: [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + searches: { + f: false, + fi: false, + fir: false, + firs: false, + first: true, + t: false, + tw: false, + two: true, + "two ": true, + "two w": true, + "two wo": true, + "two wor": true, + "two word": true, + "two words": true, + a: true, + "a ": true, + "a b": true, + "a b ": true, + "a b c": true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + searches: { + first: false, + two: false, + a: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + searches: { + "two ": false, + "a ": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: { + "two w": false, + "a b": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: {}, + }, + ], + }); +}); + +// Tests "show less frequently" with the cap set in both Nimbus and remote +// settings. Nimbus should override remote settings. +add_task(async function showLessFrequently_nimbus() { + await doShowLessFrequentlyTest({ + nimbus: { + addonsShowLessFrequentlyCap: 3, + }, + rs: { + show_less_frequently_cap: 10, + }, + tests: [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + searches: { + a: true, + "a ": true, + "a b": true, + "a b ": true, + "a b c": true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + searches: { + a: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + searches: { + "a ": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: { + "a b": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: {}, + }, + ], + }); +}); + +/** + * Does a group of searches, increments the `showLessFrequentlyCount`, and + * repeats until all groups are done. The cap can be set by remote settings + * config and/or Nimbus. + * + * @param {object} options + * Options object. + * @param {object} options.tests + * An array where each item describes a group of searches to perform and + * expected state. Each item should look like this: + * `{ showLessFrequentlyCount, canShowLessFrequently, searches }` + * + * {number} showLessFrequentlyCount + * The expected value of `showLessFrequentlyCount` before the group of + * searches is performed. + * {boolean} canShowLessFrequently + * The expected value of `canShowLessFrequently` before the group of + * searches is performed. + * {object} searches + * An object that maps each search string to a boolean that indicates + * whether the first remote settings suggestion should be triggered by the + * search string. `searches` objects are cumulative: The intended use is to + * pass a large initial group of searches in the first search group, and + * then each following `searches` is a diff against the previous. + * @param {object} options.rs + * The remote settings config to set. + * @param {object} options.nimbus + * The Nimbus variables to set. + */ +async function doShowLessFrequentlyTest({ tests, rs = {}, nimbus = {} }) { + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + // We'll be testing with the first remote settings suggestion. + let suggestion = REMOTE_SETTINGS_RESULTS[0].attachment[0]; + + let addonSuggestions = QuickSuggest.getFeature("AddonSuggestions"); + + // Set Nimbus variables and RS config. + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(nimbus); + await QuickSuggestTestUtils.withConfig({ + config: rs, + callback: async () => { + let cumulativeSearches = {}; + + for (let { + showLessFrequentlyCount, + canShowLessFrequently, + searches, + } of tests) { + Assert.equal( + addonSuggestions.showLessFrequentlyCount, + showLessFrequentlyCount, + "showLessFrequentlyCount should be correct initially" + ); + Assert.equal( + UrlbarPrefs.get("addons.showLessFrequentlyCount"), + showLessFrequentlyCount, + "Pref should be correct initially" + ); + Assert.equal( + addonSuggestions.canShowLessFrequently, + canShowLessFrequently, + "canShowLessFrequently should be correct initially" + ); + + // Merge the current `searches` object into the cumulative object. + cumulativeSearches = { + ...cumulativeSearches, + ...searches, + }; + + for (let [searchString, isExpected] of Object.entries( + cumulativeSearches + )) { + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: !isExpected + ? [] + : [ + makeExpectedResult({ + suggestion, + source: "remote-settings", + isTopPick: true, + }), + ], + }); + } + + addonSuggestions.incrementShowLessFrequentlyCount(); + } + }, + }); + + await cleanUpNimbus(); + UrlbarPrefs.clear("addons.showLessFrequentlyCount"); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +} + +function makeExpectedResult({ suggestion, source, isTopPick }) { + let rating; + let number_of_ratings; + if (source === "remote-settings") { + rating = suggestion.rating; + number_of_ratings = suggestion.number_of_ratings; + } else { + rating = suggestion.custom_details.amo.rating; + number_of_ratings = suggestion.custom_details.amo.number_of_ratings; + } + + return { + isBestMatch: isTopPick, + suggestedIndex: isTopPick ? 1 : -1, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "amo", + dynamicType: "addons", + title: suggestion.title, + url: suggestion.url, + displayUrl: suggestion.url.replace(/^https:\/\//, ""), + icon: suggestion.icon, + description: suggestion.description, + rating: Number(rating), + reviews: Number(number_of_ratings), + shouldNavigate: true, + helpUrl: QuickSuggest.HELP_URL, + source, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js new file mode 100644 index 0000000000..853073a6c0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js @@ -0,0 +1,463 @@ +/* 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/. */ + +// Tests best match quick suggest results. "Best match" refers to two different +// concepts: +// +// (1) The "best match" UI treatment (labeled "top pick" in the UI) that makes a +// result's row larger than usual and sets `suggestedIndex` to 1. +// (2) The quick suggest config in remote settings can contain a `best_match` +// object that tells Firefox to use the best match UI treatment if the +// user's search string is a certain length. +// +// This file tests aspects of both concepts. +// +// See also test_quicksuggest_topPicks.js. "Top picks" refer to a similar +// concept but it is not related to (2). + +"use strict"; + +const MAX_RESULT_COUNT = UrlbarPrefs.get("maxRichResults"); + +// This search string length needs to be >= 4 to trigger its suggestion as a +// best match instead of a usual quick suggest. +const BEST_MATCH_POSITION_SEARCH_STRING = "bestmatchposition"; +const BEST_MATCH_POSITION = Math.round(MAX_RESULT_COUNT / 2); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://example.com/", + title: "Fullkeyword title", + keywords: [ + "fu", + "ful", + "full", + "fullk", + "fullke", + "fullkey", + "fullkeyw", + "fullkeywo", + "fullkeywor", + "fullkeyword", + ], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + }, + { + id: 2, + url: "http://example.com/best-match-position", + title: `${BEST_MATCH_POSITION_SEARCH_STRING} title`, + keywords: [BEST_MATCH_POSITION_SEARCH_STRING], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + position: BEST_MATCH_POSITION, + }, +]; + +const EXPECTED_BEST_MATCH_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + isBestMatch: true, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/", + originalUrl: "http://example.com/", + title: "Fullkeyword title", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("bestMatchBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com", + source: "remote-settings", + }, +}; + +const EXPECTED_NON_BEST_MATCH_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/", + originalUrl: "http://example.com/", + title: "Fullkeyword title", + qsSuggestion: "fullkeyword", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com", + source: "remote-settings", + }, +}; + +const EXPECTED_BEST_MATCH_POSITION_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + isBestMatch: true, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/best-match-position", + originalUrl: "http://example.com/best-match-position", + title: `${BEST_MATCH_POSITION_SEARCH_STRING} title`, + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("bestMatchBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com/best-match-position", + source: "remote-settings", + }, +}; + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.bestmatch", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + config: QuickSuggestTestUtils.BEST_MATCH_CONFIG, + }); +}); + +// Tests a best match result. +add_task(async function bestMatch() { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // The title should not include the full keyword and em dash, and the part of + // the title that the search string matches should be highlighted. + Assert.equal(result.title, "Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [[0, "fullkeyword".length]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, 1, "result.suggestedIndex"); + Assert.equal( + !!result.isSuggestedIndexRelativeToGroup, + false, + "result.isSuggestedIndexRelativeToGroup" + ); +}); + +// Tests a usual, non-best match quick suggest result. +add_task(async function nonBestMatch() { + // Search for a substring of the full search string so we can test title + // highlights. + let context = createContext("fu", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + Assert.equal(result.title, "fullkeyword — Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [["fu".length, "fullkeyword".length - "fu".length]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, -1, "result.suggestedIndex"); + Assert.equal( + result.isSuggestedIndexRelativeToGroup, + true, + "result.isSuggestedIndexRelativeToGroup" + ); +}); + +// Tests prefix keywords leading up to a best match. +add_task(async function prefixKeywords() { + let sawNonBestMatch = false; + let sawBestMatch = false; + for (let keyword of REMOTE_SETTINGS_RESULTS[0].keywords) { + info(`Searching for "${keyword}"`); + let context = createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + let expectedResult; + if (keyword.length < 4) { + expectedResult = EXPECTED_NON_BEST_MATCH_URLBAR_RESULT; + sawNonBestMatch = true; + } else { + expectedResult = EXPECTED_BEST_MATCH_URLBAR_RESULT; + sawBestMatch = true; + } + + await check_results({ + context, + matches: [expectedResult], + }); + } + + Assert.ok(sawNonBestMatch, "Sanity check: Saw a non-best match"); + Assert.ok(sawBestMatch, "Sanity check: Saw a best match"); +}); + +// When tab-to-search is shown in the same search, both it and the best match +// will have a `suggestedIndex` value of 1. The TTS should appear first. +add_task(async function tabToSearch() { + // Disable tab-to-search onboarding results so we get a regular TTS result, + // which we can test a little more easily with `makeSearchResult()`. + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 0); + + // Install a test engine. The main part of its domain name needs to match the + // best match result too so we can trigger both its TTS and the best match. + let engineURL = "https://foo.fullkeyword.com/"; + let extension = await SearchTestUtils.installSearchExtension( + { + name: "Test", + search_url: engineURL, + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Test"); + + // Also need to add a visit to trigger TTS. + await PlacesTestUtils.addVisits(engineURL); + + let context = createContext("fullkeyword", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.iconURI?.spec, + heuristic: true, + }), + // tab to search + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + // best match + EXPECTED_BEST_MATCH_URLBAR_RESULT, + // visit + makeVisitResult(context, { + uri: engineURL, + title: `test visit for ${engineURL}`, + }), + ], + }); + + await cleanupPlaces(); + await extension.unload(); + + UrlbarPrefs.clear("tabToSearch.onboard.interactionsLeft"); +}); + +// When the best match feature gate is disabled, quick suggest results should be +// shown as the usual non-best match results. +add_task(async function disabled_featureGate() { + UrlbarPrefs.set("bestMatch.enabled", false); + await doDisabledTest(); + UrlbarPrefs.set("bestMatch.enabled", true); +}); + +// When the best match suggestions are disabled, quick suggest results should be +// shown as the usual non-best match results. +add_task(async function disabled_suggestions() { + UrlbarPrefs.set("suggest.bestmatch", false); + await doDisabledTest(); + UrlbarPrefs.set("suggest.bestmatch", true); +}); + +// When best match is disabled, quick suggest results should be shown as the +// usual, non-best match results. +async function doDisabledTest() { + let context = createContext("fullkeywor", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + Assert.equal(result.title, "fullkeyword — Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [["fullkeywor".length, 1]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, -1, "result.suggestedIndex"); + Assert.equal( + result.isSuggestedIndexRelativeToGroup, + true, + "result.isSuggestedIndexRelativeToGroup" + ); +} + +// `suggestion.position` should be ignored when the suggestion is a best match. +add_task(async function position() { + Assert.greater( + BEST_MATCH_POSITION, + 1, + "Precondition: `suggestion.position` > the best match index" + ); + + UrlbarPrefs.set("quicksuggest.allowPositionInSuggestions", true); + + let context = createContext(BEST_MATCH_POSITION_SEARCH_STRING, { + isPrivate: false, + }); + + // Add some visits to fill up the view. + let maxResultCount = UrlbarPrefs.get("maxRichResults"); + let visitResults = []; + for (let i = 0; i < maxResultCount; i++) { + let url = `http://example.com/${BEST_MATCH_POSITION_SEARCH_STRING}-${i}`; + await PlacesTestUtils.addVisits(url); + visitResults.unshift( + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + }) + ); + } + + // Do a search. + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.iconURI?.spec, + heuristic: true, + }), + // best match whose backing suggestion has a `position` + EXPECTED_BEST_MATCH_POSITION_URLBAR_RESULT, + // visits + ...visitResults.slice(0, MAX_RESULT_COUNT - 2), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("quicksuggest.allowPositionInSuggestions"); +}); + +// Tests a suggestion that is blocked from being a best match. +add_task(async function blockedAsBestMatch() { + let config = QuickSuggestTestUtils.BEST_MATCH_CONFIG; + config.best_match.blocked_suggestion_ids = [1]; + await QuickSuggestTestUtils.withConfig({ + config, + callback: async () => { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + }, + }); +}); + +// Tests without a best_match config to make sure nothing breaks. +add_task(async function noConfig() { + await QuickSuggestTestUtils.withConfig({ + config: {}, + callback: async () => { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + }, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js new file mode 100644 index 0000000000..04859c7404 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js @@ -0,0 +1,95 @@ +/* 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/. */ + +// Tests dynamic Wikipedia quick suggest results. + +"use strict"; + +const MERINO_SUGGESTIONS = [ + { + title: "title", + url: "url", + is_sponsored: false, + score: 0.23, + description: "description", + icon: "icon", + full_keyword: "full_keyword", + advertiser: "dynamic-wikipedia", + block_id: 0, + impression_url: "impression_url", + click_url: "click_url", + provider: "wikipedia", + }, +]; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// When non-sponsored suggestions are disabled, dynamic Wikipedia suggestions +// should be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Dynamic Wikipedia suggestions are + // non-sponsored, so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +function makeExpectedResult() { + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + suggestedIndex: -1, + payload: { + telemetryType: "wikipedia", + title: "title", + url: "url", + displayUrl: "url", + isSponsored: false, + icon: "icon", + qsSuggestion: "full_keyword", + source: "merino", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js new file mode 100644 index 0000000000..41706dabd8 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js @@ -0,0 +1,3888 @@ +/* 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/. */ + +// Tests impression frequency capping for quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "5 - Education", + }, +]; + +const EXPECTED_SPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/sponsored", + originalUrl: "http://example.com/sponsored", + displayUrl: "http://example.com/sponsored", + title: "Sponsored suggestion", + qsSuggestion: "sponsored", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "22 - Shopping", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, +}; + +const EXPECTED_NONSPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + url: "http://example.com/nonsponsored", + originalUrl: "http://example.com/nonsponsored", + displayUrl: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + qsSuggestion: "nonsponsored", + icon: null, + isSponsored: false, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, +}; + +let gSandbox; +let gDateNowStub; +let gStartupDateMsStub; + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true); + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("bestMatch.enabled", false); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + // Set up a sinon stub for the `Date.now()` implementation inside of + // UrlbarProviderQuickSuggest. This lets us test searches performed at + // specific times. See `doTimedCallbacks()` for more info. + gSandbox = sinon.createSandbox(); + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + + // Set up a sinon stub for `UrlbarProviderQuickSuggest._getStartupDateMs()` to + // let the test override the startup date. + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +// Tests a single interval. +add_task(async function oneInterval() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + 2: { + results: [[]], + }, + 3: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 4: { + results: [[]], + }, + 5: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests multiple intervals. +add_task(async function multipleIntervals() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: 1 new impression; 5 impressions total + 6: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 5 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 8s: no new impressions; 5 impressions total + 8: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "8000", + intervalSeconds: "1", + maxCount: "1", + startDate: "7000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 9s: no new impressions; 5 impressions total + 9: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "9000", + intervalSeconds: "1", + maxCount: "1", + startDate: "8000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 10s: 1 new impression; 6 impressions total + 10: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "9000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "5", + maxCount: "3", + startDate: "5000", + impressionDate: "6000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 11s: 1 new impression; 7 impressions total + 11: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 12s: 1 new impression; 8 impressions total + 12: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 13s: no new impressions; 8 impressions total + 13: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "13000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 14s: no new impressions; 8 impressions total + 14: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "14000", + intervalSeconds: "1", + maxCount: "1", + startDate: "13000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 15s: 1 new impression; 9 impressions total + 15: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "14000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 16s: 1 new impression; 10 impressions total + 16: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 17s: no new impressions; 10 impressions total + 17: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "17000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 18s: no new impressions; 10 impressions total + 18: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "18000", + intervalSeconds: "1", + maxCount: "1", + startDate: "17000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 19s: no new impressions; 10 impressions total + 19: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "19000", + intervalSeconds: "1", + maxCount: "1", + startDate: "18000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 20s: 1 new impression; 11 impressions total + 20: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "19000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "5", + maxCount: "3", + startDate: "15000", + impressionDate: "16000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "20000", + impressionDate: "20000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests a lifetime cap. +add_task(async function lifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [ + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [], + ], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests one interval and a lifetime cap together. +add_task(async function intervalAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests multiple intervals and a lifetime cap together. +add_task(async function multipleIntervalsAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for non-sponsored caps. Most tasks use sponsored results and caps, +// but sponsored and non-sponsored should behave the same since they use the +// same code paths. +add_task(async function nonsponsored() { + await doTest({ + config: { + impression_caps: { + nonsponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("nonsponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for sponsored and non-sponsored caps together. Most tasks use only +// sponsored results and caps, but sponsored and non-sponsored should behave the +// same since they use the same code paths. +add_task(async function sponsoredAndNonsponsored() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 2, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + // 1st searches + await checkSearch({ + name: "sponsored 1", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 1", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + + // 2nd searches + await checkSearch({ + name: "sponsored 2", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 2", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + // 3rd searches + await checkSearch({ + name: "sponsored 3", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 3", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + // 4th searches + await checkSearch({ + name: "sponsored 4", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 4", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with an empty config to make sure results are not capped. +add_task(async function emptyConfig() { + await doTest({ + config: {}, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with sponsored caps disabled. Non-sponsored should still be capped. +add_task(async function sponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 0, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true); +}); + +// Tests with non-sponsored caps disabled. Sponsored should still be capped. +add_task(async function nonsponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + nonsponsored: { + lifetime: 0, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap already reached +add_task(async function configChange_sameIntervalLowerCap_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap not reached +add_task(async function configChange_sameIntervalLowerCap_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with higher cap +add_task(async function configChange_sameIntervalHigherCap() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 5 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 3: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "3000", + impressionDate: "3000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 2 new intervals with higher timeouts. +// Impression counts for the old interval should contribute to the new +// intervals. +add_task(async function configChange_1IntervalTo2NewIntervalsHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [ + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 4: async () => { + await checkSearch({ + name: "4s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 5: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "5s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "5s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "5000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 2 intervals -> 1 new interval with higher timeout. +// Impression counts for the old intervals should contribute to the new +// interval. +add_task(async function configChange_2IntervalsTo1NewIntervalHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 2, max_count: 2 }, + { interval_s: 4, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 2: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "2s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "2000", + impressionDate: "2000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "4", + maxCount: "4", + startDate: "0", + impressionDate: "2000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 6, max_count: 5 }], + }, + }, + }); + }, + 4: async () => { + await checkSearch({ + name: "4s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "4s 1", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "4000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 5: async () => { + await checkSearch({ + name: "5s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 6: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "6s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "6s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "6000", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 1 new interval with lower timeout. +// Impression counts for the old interval should not contribute to the new +// interval since the new interval has a lower timeout. +add_task(async function configChange_1IntervalTo1NewIntervalLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 5, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "1000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> lifetime. +// Impression counts for the old interval should contribute to the new lifetime +// cap. +add_task(async function configChange_1IntervalToLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> higher lifetime cap +add_task(async function configChange_lifetimeCapHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 5, + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "Infinity", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> lower lifetime cap +add_task(async function configChange_lifetimeCapLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 1, + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Makes sure stats are serialized to and from the pref correctly. +add_task(async function prefSync() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [ + { interval_s: 3, max_count: 2 }, + { interval_s: 5, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + + let json = UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); + Assert.ok(json, "JSON is non-empty"); + Assert.deepEqual( + JSON.parse(json), + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: null, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "JSON is correct" + ); + + QuickSuggest.impressionCaps._test_reloadStats(); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "Impression stats were properly restored from the pref" + ); + }, + }); +}); + +// Tests direct changes to the stats pref. +add_task(async function prefDirectlyChanged() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + let expectedStats = { + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }; + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", "bogus"); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for 'bogus'" + ); + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", JSON.stringify({})); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for {}" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ sponsored: "bogus" }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for { sponsored: 'bogus' }" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: "bogus", + count: 0, + maxCount: 99, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with intervalSeconds: 'bogus'" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 123, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 456, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with `maxCount` values different from caps" + ); + + let stats = { + sponsored: [ + { + intervalSeconds: 3, + count: 1, + maxCount: 3, + startDateMs: 99, + impressionDateMs: 99, + }, + { + intervalSeconds: Infinity, + count: 7, + maxCount: 5, + startDateMs: 1337, + impressionDateMs: 1337, + }, + ], + }; + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify(stats) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + stats, + "Expected stats with valid JSON" + ); + }, + }); +}); + +// Tests multiple interval periods where the cap is not hit. Telemetry should be +// recorded for these periods. +add_task(async function intervalsElapsedButCapNotHit() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + // 1s + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + }, + // 10s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + let expectedEvents = [ + // 1s: reset with count = 0 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // 2-10s: reset with count = 1, eventCount = 9 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "3", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "9", + }, + }, + ]; + await checkTelemetryEvents(expectedEvents); + }, + }); + }, + }); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 4.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 6 batched resets for periods starting at 4s +add_task(async function restart_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(4500); + await doTimedCallbacks({ + // 10s: 6 batched resets for periods starting at 4s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "6", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_3() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5500); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Resets triggered at 9s, 10s, 19s, 20s +// +// Expected: +// At 10s: 1 reset for period starting at 0s +// At 20s: 1 reset for period starting at 10s +add_task(async function restart_4() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 9s: no resets + 9: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 10s: 1 reset for period starting at 0s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Reset triggered at 20s +// +// Expected: +// At 20s: 2 batched resets for periods starting at 0s +add_task(async function restart_5() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 20s: 2 batches resets for periods starting at 0s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Resets triggered at 19s, 20s, 29s, 30s +// +// Expected: +// At 20s: 1 reset for period starting at 10s +// At 30s: 1 reset for period starting at 20s +add_task(async function restart_6() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 29s: no resets + 29: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 30s: 1 reset for period starting at 20s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "20000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Reset triggered at 30s +// +// Expected: +// At 30s: 2 batched resets for periods starting at 10s +add_task(async function restart_7() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 30s: 2 batched resets for periods starting at 10s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Tests reset telemetry recorded on shutdown. +add_task(async function shutdown() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Make `Date.now()` return 10s. Since the cap's `interval_s` is 1s and + // before this `Date.now()` returned 0s, 10 reset events should be + // recorded on shutdown. + gDateNowStub.returns(10000); + + // Simulate shutdown. + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileChangeTeardown._trigger(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "10", + }, + }, + ]); + + gDateNowStub.returns(0); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + }, + }); +}); + +// Tests the reset interval in realtime. +add_task(async function resetInterval() { + // Remove the test stubs so we can test in realtime. + gDateNowStub.restore(); + gStartupDateMsStub.restore(); + + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 0.1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Restart the reset interval now with a 1s period. Since the cap's + // `interval_s` is 0.1s, at least 10 reset events should be recorded the + // first time the reset interval fires. The exact number depends on timing + // and the machine running the test: how many 0.1s intervals elapse + // between when the config is set to when the reset interval fires. For + // that reason, we allow some leeway when checking `eventCount` below to + // avoid intermittent failures. + QuickSuggest.impressionCaps._test_setCountersResetInterval(1000); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1100)); + + // Restore the reset interval to its default. + QuickSuggest.impressionCaps._test_setCountersResetInterval(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: /^[0-9]+$/, + intervalSeconds: "0.1", + maxCount: "1", + startDate: /^[0-9]+$/, + impressionDate: "0", + count: "0", + type: "sponsored", + // See comment above on allowing leeway for `eventCount`. + eventCount: str => { + info(`Checking 'eventCount': ${str}`); + let count = parseInt(str); + return 10 <= count && count < 20; + }, + }, + }, + ]); + }, + }); + + // Recreate the test stubs. + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +/** + * Main test helper. Sets up state, calls your callback, and resets state. + * + * @param {object} options + * Options object. + * @param {object} options.config + * The quick suggest config to use during the test. + * @param {Function} options.callback + * The callback that will be run with the {@link config} + */ +async function doTest({ config, callback }) { + Services.telemetry.clearEvents(); + + // Make `Date.now()` return 0 to start with. It's necessary to do this before + // calling `withConfig()` because when a new config is set, the provider + // validates its impression stats, whose `startDateMs` values depend on + // `Date.now()`. + gDateNowStub.returns(0); + + info(`Clearing stats and setting config`); + UrlbarPrefs.clear("quicksuggest.impressionCaps.stats"); + QuickSuggest.impressionCaps._test_reloadStats(); + await QuickSuggestTestUtils.withConfig({ config, callback }); +} + +/** + * Does a series of timed searches and checks their results and telemetry. This + * function relies on `doTimedCallbacks()`, so it may be helpful to look at it + * too. + * + * @param {string} searchString + * The query that should be timed + * @param {object} expectedBySecond + * An object that maps from seconds to objects that describe the searches to + * perform, their expected results, and the expected telemetry. For a given + * entry `S -> E` in this object, searches are performed S seconds after this + * function is called. `E` is an object that looks like this: + * + * { results, telemetry } + * + * {array} results + * An array of arrays. A search is performed for each sub-array in + * `results`, and the contents of the sub-array are the expected results + * for that search. + * {object} telemetry + * An object like this: { events } + * {array} events + * An array of expected telemetry events after all searches are done. + * Telemetry events are cleared after checking these. If not present, + * then it will be asserted that no events were recorded. + * + * Example: + * + * { + * 0: { + * results: [[R1], []], + * telemetry: { + * events: [ + * someExpectedEvent, + * ], + * }, + * } + * 1: { + * results: [[]], + * }, + * } + * + * 0 seconds after `doTimedSearches()` is called, two searches are + * performed. The first one is expected to return a single result R1, and + * the second search is expected to return no results. After the searches + * are done, one telemetry event is expected to be recorded. + * + * 1 second after `doTimedSearches()` is called, one search is performed. + * It's expected to return no results, and no telemetry is expected to be + * recorded. + */ +async function doTimedSearches(searchString, expectedBySecond) { + await doTimedCallbacks( + Object.entries(expectedBySecond).reduce( + (memo, [second, { results, telemetry }]) => { + memo[second] = async () => { + for (let i = 0; i < results.length; i++) { + let expectedResults = results[i]; + await checkSearch({ + searchString, + expectedResults, + name: `${second}s search ${i + 1} of ${results.length}`, + }); + } + let { events } = telemetry || {}; + await checkTelemetryEvents(events || []); + }; + return memo; + }, + {} + ) + ); +} + +/** + * Takes a series a callbacks and times at which they should be called, and + * calls them accordingly. This function is specifically designed for + * UrlbarProviderQuickSuggest and its impression capping implementation because + * it works by stubbing `Date.now()` within UrlbarProviderQuickSuggest. The + * callbacks are not actually called at the given times but instead `Date.now()` + * is stubbed so that UrlbarProviderQuickSuggest will think they are being + * called at the given times. + * + * A more general implementation of this helper function that isn't tailored to + * UrlbarProviderQuickSuggest is commented out below, and unfortunately it + * doesn't work properly on macOS. + * + * @param {object} callbacksBySecond + * An object that maps from seconds to callback functions. For a given entry + * `S -> F` in this object, the callback F is called S seconds after + * `doTimedCallbacks()` is called. + */ +async function doTimedCallbacks(callbacksBySecond) { + let entries = Object.entries(callbacksBySecond).sort(([t1], [t2]) => t1 - t2); + for (let [timeoutSeconds, callback] of entries) { + gDateNowStub.returns(1000 * timeoutSeconds); + await callback(); + } +} + +/* +// This is the original implementation of `doTimedCallbacks()`, left here for +// reference or in case the macOS problem described below is fixed. Instead of +// stubbing `Date.now()` within UrlbarProviderQuickSuggest, it starts parallel +// timers so that the callbacks are actually called at appropriate times. This +// version of `doTimedCallbacks()` is therefore more generally useful, but it +// has the drawback that your test has to run in real time. e.g., if one of your +// callbacks needs to run 10s from now, the test must actually wait 10s. +// +// Unfortunately macOS seems to have some kind of limit of ~33 total 1-second +// timers during any xpcshell test -- not 33 simultaneous timers but 33 total +// timers. After that, timers fire randomly and with huge timeout periods that +// are often exactly 10s greater than the specified period, as if some 10s +// timeout internal to macOS is being hit. This problem does not seem to happen +// when running the full browser, only during xpcshell tests. In fact the +// problem can be reproduced in an xpcshell test that simply creates an interval +// timer whose period is 1s (e.g., using `setInterval()` from Timer.sys.mjs). +// After ~33 ticks, the timer's period jumps to ~10s. +async function doTimedCallbacks(callbacksBySecond) { + await Promise.all( + Object.entries(callbacksBySecond).map( + ([timeoutSeconds, callback]) => new Promise( + resolve => setTimeout( + () => callback().then(resolve), + 1000 * parseInt(timeoutSeconds) + ) + ) + ) + ); +} +*/ + +/** + * Does a search, triggers an engagement, and checks the results. + * + * @param {object} options + * Options object. + * @param {string} options.name + * This value is the name of the search and will be logged in messages to make + * debugging easier. + * @param {string} options.searchString + * The query that should be searched. + * @param {Array} options.expectedResults + * The results that are expected from the search. + */ +async function checkSearch({ name, searchString, expectedResults }) { + info(`Preparing search "${name}" with search string "${searchString}"`); + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + info(`Doing search: ${name}`); + await check_results({ + context, + matches: expectedResults, + }); + info(`Finished search: ${name}`); + + // Impression stats are updated only on engagement, so force one now. + // `selIndex` doesn't really matter but since we're not trying to simulate a + // click on the suggestion, pass in -1 to ensure we don't record a click. Pass + // in true for `isPrivate` so we don't attempt to record the impression ping + // because otherwise the following PingCentre error is logged: + // "Structured Ingestion ping failure with error: undefined" + let isPrivate = true; + if (UrlbarProviderQuickSuggest._resultFromLastQuery) { + UrlbarProviderQuickSuggest._resultFromLastQuery.isVisible = true; + } + UrlbarProviderQuickSuggest.onEngagement(isPrivate, "engagement", context, { + selIndex: -1, + }); +} + +async function checkTelemetryEvents(expectedEvents) { + QuickSuggestTestUtils.assertEvents( + expectedEvents.map(event => ({ + ...event, + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "impression_cap", + })), + // Filter in only impression_cap events. + { method: "impression_cap" } + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js new file mode 100644 index 0000000000..4327890a0d --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -0,0 +1,681 @@ +/* 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/. */ + +// Tests Merino integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// relative to `browser.urlbar` +const PREF_DATA_COLLECTION_ENABLED = "quicksuggest.dataCollection.enabled"; +const PREF_MERINO_ENABLED = "merino.enabled"; +const PREF_REMOTE_SETTINGS_ENABLED = "quicksuggest.remoteSettings.enabled"; + +const SEARCH_STRING = "frab"; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: [SEARCH_STRING], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + }, +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: SEARCH_STRING, + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +const EXPECTED_MERINO_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "full_keyword", + title: "title", + url: "url", + originalUrl: "url", + icon: null, + sponsoredImpressionUrl: "impression_url", + sponsoredClickUrl: "click_url", + sponsoredBlockId: 1, + sponsoredAdvertiser: "advertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "url", + requestId: "request_id", + source: "merino", + }, +}; + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +XPCOMUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", false); + + await MerinoTestUtils.server.start(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + Assert.equal( + typeof QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + "number", + "Sanity check: DEFAULT_SUGGESTION_SCORE is defined" + ); +}); + +// Tests with Merino enabled and remote settings disabled. +add_task(async function oneEnabled_merino() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // Use a score lower than the remote settings score to make sure the + // suggestion is included regardless. + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE / 2; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Tests with Merino disabled and remote settings enabled. +add_task(async function oneEnabled_remoteSettings() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, false); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: false, + client: gClient, + }); +}); + +// Tests with Merino enabled but with data collection disabled. Results should +// not be fetched from Merino in that case. Also tests with remote settings +// enabled. +add_task(async function dataCollectionDisabled() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, false); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); +}); + +// When the Merino suggestion has a higher score than the remote settings +// suggestion, the Merino suggestion should be used. +add_task(async function higherScore() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + 2 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion has a lower score than the remote settings +// suggestion, the remote settings suggestion should be used. +add_task(async function lowerScore() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE / 2; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino and remote settings suggestions have the same score, the +// remote settings suggestion should be used. +add_task(async function sameScore() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion does not include a score, the remote settings +// suggestion should be used. +add_task(async function noMerinoScore() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + Assert.equal( + typeof MerinoTestUtils.server.response.body.suggestions[0].score, + "number", + "Sanity check: First suggestion has a score" + ); + delete MerinoTestUtils.server.response.body.suggestions[0].score; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When remote settings doesn't return a suggestion but Merino does, the Merino +// suggestion should be used. +add_task(async function noSuggestion_remoteSettings() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("this doesn't match remote settings", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When Merino doesn't return a suggestion but remote settings does, the remote +// settings suggestion should be used. +add_task(async function noSuggestion_merino() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = []; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Tests with both Merino and remote settings disabled. +add_task(async function bothDisabled() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, false); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: false, + client: gClient, + }); +}); + +// When Merino returns multiple suggestions, the one with the largest score +// should be used. +add_task(async function multipleMerinoSuggestions() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = [ + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 0 full_keyword", + title: "multipleMerinoSuggestions 0 title", + url: "multipleMerinoSuggestions 0 url", + icon: "multipleMerinoSuggestions 0 icon", + impression_url: "multipleMerinoSuggestions 0 impression_url", + click_url: "multipleMerinoSuggestions 0 click_url", + block_id: 0, + advertiser: "multipleMerinoSuggestions 0 advertiser", + is_sponsored: true, + score: 0.1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + impression_url: "multipleMerinoSuggestions 1 impression_url", + click_url: "multipleMerinoSuggestions 1 click_url", + block_id: 1, + advertiser: "multipleMerinoSuggestions 1 advertiser", + is_sponsored: true, + score: 1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 2 full_keyword", + title: "multipleMerinoSuggestions 2 title", + url: "multipleMerinoSuggestions 2 url", + icon: "multipleMerinoSuggestions 2 icon", + impression_url: "multipleMerinoSuggestions 2 impression_url", + click_url: "multipleMerinoSuggestions 2 click_url", + block_id: 2, + advertiser: "multipleMerinoSuggestions 2 advertiser", + is_sponsored: true, + score: 0.2, + }, + ]; + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + originalUrl: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + sponsoredImpressionUrl: "multipleMerinoSuggestions 1 impression_url", + sponsoredClickUrl: "multipleMerinoSuggestions 1 click_url", + sponsoredBlockId: 1, + sponsoredAdvertiser: "multipleMerinoSuggestions 1 advertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "multipleMerinoSuggestions 1 url", + requestId: "request_id", + source: "merino", + }, + }, + ], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_task(async function timestamps() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Set up the Merino response with template URLs. + let suggestion = MerinoTestUtils.server.response.body.suggestions[0]; + let { TIMESTAMP_TEMPLATE } = QuickSuggest; + + suggestion.url = `http://example.com/time-${TIMESTAMP_TEMPLATE}`; + suggestion.click_url = `http://example.com/time-${TIMESTAMP_TEMPLATE}-foo`; + + // Do a search. + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: suggestion.click_url, + sponsoredClickUrl: suggestion.click_url, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When both suggestion types are disabled but data collection is enabled, we +// should still send requests to Merino, and the requests should include an +// empty `providers` to tell Merino not to fetch any suggestions. +add_task(async function suggestedDisabled_dataCollectionEnabled() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Check that the request is received and includes an empty `providers`. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: "test", + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [MerinoTestUtils.SEARCH_PARAMS.PROVIDERS]: "", + }, + }, + ]); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + gClient.resetSession(); +}); + +// Test whether the blocking for Merino results works. +add_task(async function block() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + for (const suggestion of MerinoTestUtils.server.response.body.suggestions) { + await QuickSuggest.blockedSuggestions.add(suggestion.url); + } + + const context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + await QuickSuggest.blockedSuggestions.clear(); + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Tests a Merino suggestion that is a best match. +add_task(async function bestMatch() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Simply enabling the best match feature should make the mock suggestion a + // best match because the search string length is greater than the required + // best match length. + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.bestmatch", true); + + let expectedResult = { ...EXPECTED_MERINO_URLBAR_RESULT }; + expectedResult.payload = { ...EXPECTED_MERINO_URLBAR_RESULT.payload }; + expectedResult.isBestMatch = true; + delete expectedResult.payload.qsSuggestion; + + await QuickSuggestTestUtils.withConfig({ + config: QuickSuggestTestUtils.BEST_MATCH_CONFIG, + callback: async () => { + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedResult], + }); + + // This isn't necessary since `check_results()` checks `isBestMatch`, but + // check it here explicitly for good measure. + Assert.ok(context.results[0].isBestMatch, "Result is a best match"); + }, + }); + + UrlbarPrefs.clear("bestMatch.enabled"); + UrlbarPrefs.clear("suggest.bestmatch"); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js new file mode 100644 index 0000000000..935577c36c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js @@ -0,0 +1,174 @@ +/* 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/. */ + +// Tests Merino session integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +XPCOMUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("merino.enabled", true); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", false); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + + await MerinoTestUtils.server.start(); + await QuickSuggestTestUtils.ensureQuickSuggestInit(); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleEngagement() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await controller.startQuery( + createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement(); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task completes each engagement +// successfully. +add_task(async function manyEngagements_engagement() { + await doManyEngagementsTest("engagement"); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task abandons each engagement. +add_task(async function manyEngagements_abandonment() { + await doManyEngagementsTest("abandonment"); +}); + +async function doManyEngagementsTest(state) { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await controller.startQuery(context); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + endEngagement(context, state); + } +} + +// When a search is canceled after the request is sent and before the Merino +// response is received, the sequence number should still be incremented. +add_task(async function canceledQueries() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first search. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let searchString1 = "search" + i; + controller.startQuery( + createContext(searchString1, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // Wait until the first request is received before starting the second + // search. If we started the second search immediately, the first would be + // canceled before the provider is even called due to the urlbar's 50ms + // delay (see `browser.urlbar.delay`) so the sequence number would not be + // incremented for it. Here we want to test the case where the first search + // is canceled after the request is sent and the number is incremented. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + + // Now do a second search that cancels the first. + let searchString2 = searchString1 + "again"; + await controller.startQuery( + createContext(searchString2, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // The sequence number should have been incremented for each search. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString1, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString2, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement(); +}); + +function endEngagement(context = null, state = "engagement") { + UrlbarProviderQuickSuggest.onEngagement( + false, + state, + context || + createContext("endEngagement", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + { selIndex: -1 } + ); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after engagement" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after engagement" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js new file mode 100644 index 0000000000..8e80f3639a --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js @@ -0,0 +1,490 @@ +/* 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/. */ + +// Tests quick suggest prefs migration from unversioned prefs to version 1. + +"use strict"; + +// Expected version 1 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +const TEST_OVERRIDES = { + migrationVersion: 1, + defaultPrefs: DEFAULT_PREFS, +}; + +add_task(async function init() { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE TO OFFLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: remain off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE TO ONLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE TO OFFLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on (since main pref had default value) +// * Sponsored suggestions: on (since main & sponsored prefs had default values) +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on (see below) +// * Data collection: off +// +// It's unfortunate that sponsored suggestions are ultimately on since before +// the migration no suggestions were shown to the user. There's nothing we can +// do about it, aside from forcing off suggestions in more cases than we want. +// The reason is that at the time of migration we can't tell that the previous +// scenario was online -- or more precisely that it wasn't history. If we knew +// it wasn't history, then we'd know to turn sponsored off; if we knew it *was* +// history, then we'd know to turn sponsored -- and non-sponsored -- on, since +// the scenario at the time of migration is offline, where suggestions should be +// enabled by default. +// +// This is the reason we now record `quicksuggest.scenario` on the user branch +// and not the default branch as we previously did. +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// The following tasks test ONLINE TO ONLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js new file mode 100644 index 0000000000..cd4a2149e6 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js @@ -0,0 +1,1355 @@ +/* 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/. */ + +// Tests quick suggest prefs migration to version 2. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// Expected version 2 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +// Currently undefined because version 2 is the current migration version and we +// want migration to use its actual values, not overrides. When version 3 is +// added, set this to an object like the one in test_quicksuggest_migrate_v1.js. +const TEST_OVERRIDES = undefined; + +add_task(async function init() { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE UNVERSIONED to OFFLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE UNVERSIONED to ONLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, did not +// turn on either type of suggestion, was not shown the modal (e.g., because +// they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and left off sponsored suggestions, was not shown +// the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, left +// off main suggestions pref and turned on sponsored suggestions, was not +// shown the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and sponsored suggestions, was not shown the +// modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user WAS SHOWN +// THE MODAL + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in and left off both the main suggestions pref and +// sponsored suggestions +// 2. User opted in but then later turned off both the main suggestions pref +// and sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on the main suggestions pref +// 2. User opted in but then later turned off sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on sponsored suggestions +// 2. User opted in but then later turned off the main suggestions pref +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on both the main suggestions +// pref and sponsored suggestions +// 2. User opted in and left on both the main suggestions pref and sponsored +// suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to OFFLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to ONLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE UNVERSIONED + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE VERSION 1 + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "not_now_link", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "accept", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +async function withOnlineExperiment(callback) { + let { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe("firefox-suggest-offline-vs-online", { + active: true, + branches: [ + { + slug: "treatment", + features: [ + { + featureId: NimbusFeatures.urlbar.featureId, + value: { + enabled: true, + }, + }, + ], + }, + ], + }) + ); + await enrollmentPromise; + await callback(); + await doExperimentCleanup(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js new file mode 100644 index 0000000000..84d1116e89 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js @@ -0,0 +1,282 @@ +/* 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/. */ + +// Tests non-unique keywords, i.e., keywords used by multiple suggestions. + +"use strict"; + +// For each of these objects, the test creates a quick suggest result (the kind +// stored in the remote settings data, not a urlbar result), the corresponding +// expected quick suggest suggestion, and the corresponding expected urlbar +// result. The test assumes results and suggestions are returned in the order +// listed here. +let SUGGESTIONS_DATA = [ + { + keywords: ["aaa"], + isSponsored: true, + }, + { + keywords: ["aaa", "bbb"], + isSponsored: false, + score: 2 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: true, + score: 4 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: false, + score: 3 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["ccc"], + isSponsored: true, + }, +]; + +// Test cases. In this object, keywords map to subtest cases. For each keyword, +// the test calls `query(keyword)` and checks that the indexes (relative to +// `SUGGESTIONS_DATA`) of the returned quick suggest results are the ones in +// `expectedIndexes`. Then the test does a series of urlbar searches using the +// keyword as the search string, one search per object in `searches`. Sponsored +// and non-sponsored urlbar results are enabled as defined by `sponsored` and +// `nonsponsored`. `expectedIndex` is the expected index (relative to +// `SUGGESTIONS_DATA`) of the returned urlbar result. +let TESTS = { + aaa: { + // 0: sponsored + // 1: nonsponsored, score = 2x + expectedIndexes: [0, 1], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 0, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + bbb: { + // 1: nonsponsored, score = 2x + // 2: sponsored, score = 4x, + // 3: nonsponsored, score = 3x + expectedIndexes: [1, 2, 3], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 3, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + ccc: { + // 4: sponsored + expectedIndexes: [4], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: undefined, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, +}; + +add_task(async function () { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Create results and suggestions based on `SUGGESTIONS_DATA`. + let qsResults = []; + let qsSuggestions = []; + let urlbarResults = []; + for (let i = 0; i < SUGGESTIONS_DATA.length; i++) { + let { keywords, isSponsored, score } = SUGGESTIONS_DATA[i]; + + // quick suggest result + let qsResult = { + keywords, + score, + id: i, + url: "http://example.com/" + i, + title: "Title " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: isSponsored ? "22 - Shopping" : "5 - Education", + }; + qsResults.push(qsResult); + + // expected quick suggest suggestion + let qsSuggestion = { + ...qsResult, + block_id: qsResult.id, + is_sponsored: isSponsored, + score: + typeof score == "number" + ? score + : QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + source: "remote-settings", + icon: null, + position: undefined, + provider: "AdmWikipedia", + }; + delete qsSuggestion.keywords; + delete qsSuggestion.id; + qsSuggestions.push(qsSuggestion); + + // expected urlbar result + urlbarResults.push({ + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + isSponsored, + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + sponsoredBlockId: qsResult.id, + url: qsResult.url, + originalUrl: qsResult.url, + displayUrl: qsResult.url, + title: qsResult.title, + sponsoredClickUrl: qsResult.click_url, + sponsoredImpressionUrl: qsResult.impression_url, + sponsoredAdvertiser: qsResult.advertiser, + sponsoredIabCategory: qsResult.iab_category, + icon: null, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }); + } + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: qsResults, + }, + ], + }); + + // Run a test for each keyword. + for (let [keyword, test] of Object.entries(TESTS)) { + info("Running subtest " + JSON.stringify({ keyword, test })); + + let { expectedIndexes, searches } = test; + + // Call `query()`. + Assert.deepEqual( + await QuickSuggestRemoteSettings.query(keyword), + expectedIndexes.map(i => ({ + ...qsSuggestions[i], + full_keyword: keyword, + })), + `query() for keyword ${keyword}` + ); + + // Now do a urlbar search for the keyword with all possible combinations of + // sponsored and non-sponsored suggestions enabled and disabled. + for (let sponsored of [true, false]) { + for (let nonsponsored of [true, false]) { + // Find the matching `searches` object. + let search = searches.find( + s => s.sponsored == sponsored && s.nonsponsored == nonsponsored + ); + Assert.ok( + search, + "Sanity check: Search test case specified for " + + JSON.stringify({ keyword, sponsored, nonsponsored }) + ); + + info( + "Running urlbar search subtest " + + JSON.stringify({ keyword, expectedIndexes, search }) + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", sponsored); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", nonsponsored); + + // Set up the search and do it. + let context = createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + let matches = []; + if (search.expectedIndex !== undefined) { + matches.push({ + ...urlbarResults[search.expectedIndex], + payload: { + ...urlbarResults[search.expectedIndex].payload, + qsSuggestion: keyword, + }, + }); + } + + await check_results({ context, matches }); + } + } + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js new file mode 100644 index 0000000000..7330dd4fd5 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js @@ -0,0 +1,127 @@ +/* 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/. */ + +// Tests `UrlbarPrefs.updateFirefoxSuggestScenario` in isolation under the +// assumption that the offline scenario should be enabled by default for US en. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", +}); + +// All the prefs that `updateFirefoxSuggestScenario` sets along with the +// expected default-branch values when offline is enabled and when it's not +// enabled. +const PREFS = [ + { + name: "browser.urlbar.quicksuggest.enabled", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: false, + expectedOtherValue: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, +]; + +add_task(async function init() { + await UrlbarTestUtils.initNimbusFeature(); +}); + +add_task(async function test() { + let tests = [ + { locale: "en-US", home: "US", expectedOfflineDefault: true }, + { locale: "en-US", home: "CA", expectedOfflineDefault: false }, + { locale: "en-CA", home: "US", expectedOfflineDefault: true }, + { locale: "en-CA", home: "CA", expectedOfflineDefault: false }, + { locale: "en-GB", home: "US", expectedOfflineDefault: true }, + { locale: "en-GB", home: "GB", expectedOfflineDefault: false }, + { locale: "de", home: "US", expectedOfflineDefault: false }, + { locale: "de", home: "DE", expectedOfflineDefault: false }, + ]; + for (let { locale, home, expectedOfflineDefault } of tests) { + await doTest({ locale, home, expectedOfflineDefault }); + } +}); + +/** + * Sets the app's locale and region, calls + * `UrlbarPrefs.updateFirefoxSuggestScenario`, and asserts that the pref values + * are correct. + * + * @param {object} options + * Options object. + * @param {string} options.locale + * The locale to simulate. + * @param {string} options.home + * The "home" region to simulate. + * @param {boolean} options.expectedOfflineDefault + * The expected value of whether offline should be enabled by default given + * the locale and region. + */ +async function doTest({ locale, home, expectedOfflineDefault }) { + // Setup: Clear any user values and save original default-branch values. + for (let pref of PREFS) { + Services.prefs.clearUserPref(pref.name); + pref.originalDefault = Services.prefs + .getDefaultBranch(pref.name) + [pref.get](""); + } + + // Set the region and locale, call the function, check the pref values. + Region._setHomeRegion(home, false); + await QuickSuggestTestUtils.withLocales([locale], async () => { + await UrlbarPrefs.updateFirefoxSuggestScenario(); + for (let { name, get, expectedOfflineValue, expectedOtherValue } of PREFS) { + let expectedValue = expectedOfflineDefault + ? expectedOfflineValue + : expectedOtherValue; + + // Check the default-branch value. + Assert.strictEqual( + Services.prefs.getDefaultBranch(name)[get](""), + expectedValue, + `Default pref value for ${name}, locale ${locale}, home ${home}` + ); + + // For good measure, also check the return value of `UrlbarPrefs.get` + // since we use it everywhere. The value should be the same as the + // default-branch value. + UrlbarPrefs.get( + name.replace("browser.urlbar.", ""), + expectedValue, + `UrlbarPrefs.get() value for ${name}, locale ${locale}, home ${home}` + ); + } + }); + + // Teardown: Restore original default-branch values for the next task. + for (let { name, originalDefault, set } of PREFS) { + if (originalDefault === undefined) { + Services.prefs.deleteBranch(name); + } else { + Services.prefs.getDefaultBranch(name)[set]("", originalDefault); + } + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js new file mode 100644 index 0000000000..2d1cf728c7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js @@ -0,0 +1,487 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for quick suggest result position specified in suggestions. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderHeuristicFallback: + "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs", + UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +const SPONSORED_SECOND_POSITION_RESULT = { + id: 1, + url: "http://example.com/?q=sponsored-second", + title: "sponsored second", + keywords: ["s-s"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + position: 1, +}; +const SPONSORED_NORMAL_POSITION_RESULT = { + id: 2, + url: "http://example.com/?q=sponsored-normal", + title: "sponsored normal", + keywords: ["s-n"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", +}; +const NONSPONSORED_SECOND_POSITION_RESULT = { + id: 3, + url: "http://example.com/?q=nonsponsored-second", + title: "nonsponsored second", + keywords: ["n-s"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + position: 1, +}; +const NONSPONSORED_NORMAL_POSITION_RESULT = { + id: 4, + url: "http://example.com/?q=nonsponsored-normal", + title: "nonsponsored normal", + keywords: ["n-n"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", +}; +const FIRST_POSITION_RESULT = { + id: 5, + url: "http://example.com/?q=first-position", + title: "first position suggest", + keywords: ["first-position"], + click_url: "http://click.reporting.test.com/first-position", + impression_url: "http://impression.reporting.test.com/first-position", + advertiser: "TestAdvertiserFirstPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 0, +}; +const SECOND_POSITION_RESULT = { + id: 6, + url: "http://example.com/?q=second-position", + title: "second position suggest", + keywords: ["second-position"], + click_url: "http://click.reporting.test.com/second-position", + impression_url: "http://impression.reporting.test.com/second-position", + advertiser: "TestAdvertiserSecondPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 1, +}; +const THIRD_POSITION_RESULT = { + id: 7, + url: "http://example.com/?q=third-position", + title: "third position suggest", + keywords: ["third-position"], + click_url: "http://click.reporting.test.com/third-position", + impression_url: "http://impression.reporting.test.com/third-position", + advertiser: "TestAdvertiserThirdPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 2, +}; + +const TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST = + "first-position.example.com"; +const TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST = + "second-position.example.com"; + +const SECOND_POSITION_INTERVENTION_RESULT = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } +); +SECOND_POSITION_INTERVENTION_RESULT.suggestedIndex = 1; +const SECOND_POSITION_INTERVENTION_RESULT_PROVIDER = + new UrlbarTestUtils.TestProvider({ + results: [SECOND_POSITION_INTERVENTION_RESULT], + priority: 0, + name: "second_position_intervention_provider", + }); + +const EXPECTED_GENERAL_HEURISTIC_RESULT = { + providerName: UrlbarProviderHeuristicFallback.name, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: true, +}; + +const EXPECTED_GENERAL_PLACES_RESULT = { + providerName: UrlbarProviderPlaces.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +const EXPECTED_GENERAL_TABTOSEARCH_RESULT = { + providerName: UrlbarProviderTabToSearch.name, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, +}; + +const EXPECTED_GENERAL_INTERVENTION_RESULT = { + providerName: SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +function createExpectedQuickSuggestResult(suggest) { + let isSponsored = suggest.iab_category !== "5 - Education"; + return { + providerName: UrlbarProviderQuickSuggest.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: suggest.keywords[0], + title: suggest.title, + url: suggest.url, + originalUrl: suggest.url, + icon: null, + sponsoredImpressionUrl: suggest.impression_url, + sponsoredClickUrl: suggest.click_url, + sponsoredBlockId: suggest.id, + sponsoredAdvertiser: suggest.advertiser, + sponsoredIabCategory: suggest.iab_category, + isSponsored, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: suggest.url, + source: "remote-settings", + }, + }; +} + +const TEST_CASES = [ + { + description: "Test for second placable sponsored suggest", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal sponsored suggest", + input: SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: "Test for second placable nonsponsored suggest", + input: NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal nonsponsored suggest", + input: NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: + "Test for second placable sponsored suggest but secondPosition pref is disabled", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": false, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with multi providers having same index", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderTabToSearch.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with heuristic and tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with heuristic tab-to-search and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test the results with heuristic and another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: + "Test the results with heuristic, another intervention and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for 0 indexed quick suggest", + input: FIRST_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + createExpectedQuickSuggestResult(FIRST_POSITION_RESULT), + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + ], + }, + { + description: "Test for 2 indexed quick suggest", + input: THIRD_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_INTERVENTION_RESULT, + createExpectedQuickSuggestResult(THIRD_POSITION_RESULT), + ], + }, +]; + +add_task(async function setup() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Setup for quick suggest result. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: [ + SPONSORED_SECOND_POSITION_RESULT, + SPONSORED_NORMAL_POSITION_RESULT, + NONSPONSORED_SECOND_POSITION_RESULT, + NONSPONSORED_NORMAL_POSITION_RESULT, + FIRST_POSITION_RESULT, + SECOND_POSITION_RESULT, + THIRD_POSITION_RESULT, + ], + }, + ], + }); + + // Setup for places result. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + "http://example.com/" + SPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + SECOND_POSITION_RESULT.keywords[0], + ]); + + // Setup for tab-to-search result. + await SearchTestUtils.installSearchExtension({ + name: "first", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST}/`, + }); + await SearchTestUtils.installSearchExtension({ + name: "second", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST}/`, + }); + + /// Setup for another intervention result. + UrlbarProvidersManager.registerProvider( + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER + ); +}); + +add_task(async function basic() { + for (const { description, input, prefs, providers, expected } of TEST_CASES) { + info(description); + + for (let name in prefs) { + UrlbarPrefs.set(name, prefs[name]); + } + + const context = createContext(input, { + providers, + isPrivate: false, + }); + await check_results({ + context, + matches: expected, + }); + + for (let name in prefs) { + UrlbarPrefs.clear(name); + } + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js new file mode 100644 index 0000000000..dd8b9dc575 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js @@ -0,0 +1,284 @@ +/* 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/. */ + +// Tests top pick quick suggest results. "Top picks" refers to two different +// concepts: +// +// (1) Any type of suggestion from Merino can have a boolean property called +// `is_top_pick`. When true, Firefox should show the suggestion using the +// "best match" UI treatment (labeled "top pick" in the UI) that makes a +// result's row larger than usual and sets `suggestedIndex` to 1. However, +// the treatment must be enabled on Firefox via the `bestMatch.enabled` +// feature gate pref (Nimbus variable `bestMatchEnabled`) and the +// `suggest.bestMatch` pref, which corresponds to a checkbox in +// about:preferences. If the UI treatment is not enabled, Firefox should +// show the suggestion as usual. +// (2) There is a Merino provider called "top_picks" that returns a specific +// type of suggestion called "navigational suggestions". These suggestions +// also have `is_top_pick` set to true. +// +// This file tests aspects of both concepts. + +"use strict"; + +const SUGGESTION_SEARCH_STRING = "example"; +const SUGGESTION_URL = "http://example.com/"; +const SUGGESTION_URL_WWW = "http://www.example.com/"; +const SUGGESTION_URL_DISPLAY = "http://example.com"; + +const MERINO_SUGGESTIONS = [ + { + is_top_pick: true, + provider: "top_picks", + url: SUGGESTION_URL, + title: "title", + icon: "icon", + is_sponsored: false, + score: 1, + }, +]; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// When non-sponsored suggestions are disabled, navigational suggestions should +// be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Navigational suggestions are non-sponsored, + // so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + }), + ], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +// Test that bestMatch navigational suggestion results are not shown when there +// is a heuristic result for the same domain. +add_task(async function heuristicDeduplication() { + let expectedNavSuggestResult = makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + dupedHeuristic: false, + }); + + let scenarios = [ + [SUGGESTION_URL, false], + [SUGGESTION_URL_WWW, false], + ["http://exampledomain.com/", true], + ]; + + // Stub `UrlbarProviderQuickSuggest.startQuery()` so we can collect the + // results it adds for each query. + let addedResults = []; + let sandbox = sinon.createSandbox(); + let startQueryStub = sandbox.stub(UrlbarProviderQuickSuggest, "startQuery"); + startQueryStub.callsFake((queryContext, add) => { + let fakeAdd = (provider, result) => { + addedResults.push(result); + add(provider, result); + }; + return startQueryStub.wrappedMethod.call( + UrlbarProviderQuickSuggest, + queryContext, + fakeAdd + ); + }); + + for (let [url, expectBestMatch] of scenarios) { + await PlacesTestUtils.addVisits(url); + + // Do a search and check the results. + let context = createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderAutofill.name], + isPrivate: false, + }); + const EXPECTED_AUTOFILL_RESULT = makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }); + await check_results({ + context, + matches: expectBestMatch + ? [EXPECTED_AUTOFILL_RESULT, expectedNavSuggestResult] + : [EXPECTED_AUTOFILL_RESULT], + }); + + // Regardless of whether it was shown, one result should have been added and + // its `payload.dupedHeuristic` should be set properly. + Assert.equal( + addedResults.length, + 1, + "The provider should have added one result" + ); + Assert.equal( + !addedResults[0].payload.dupedHeuristic, + expectBestMatch, + "dupedHeuristic should be the opposite of expectBestMatch" + ); + addedResults = []; + + await PlacesUtils.history.clear(); + } + + sandbox.restore(); +}); + +add_task(async function prefs_0() { + await doPrefsTest({ + bestMatchEnabled: false, + suggestBestMatch: false, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_1() { + await doPrefsTest({ + bestMatchEnabled: false, + suggestBestMatch: true, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_2() { + await doPrefsTest({ + bestMatchEnabled: true, + suggestBestMatch: false, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_3() { + await doPrefsTest({ + bestMatchEnabled: true, + suggestBestMatch: true, + expected: { + isBestMatch: true, + suggestedIndex: 1, + }, + }); +}); + +async function doPrefsTest({ + bestMatchEnabled, + suggestBestMatch, + expected: { isBestMatch, suggestedIndex }, +}) { + UrlbarPrefs.set("bestMatch.enabled", bestMatchEnabled); + UrlbarPrefs.set("suggest.bestmatch", suggestBestMatch); + + // The mock suggestion has `provider` set to "top_picks", but Firefox should + // use only `is_top_pick` to determine whether it should be shown as best + // match, regardless of the provider. To make sure, change the provider to + // something else. + let originalProviders = []; + let provider = "some_unknown_provider"; + for (let s of MerinoTestUtils.server.response.body.suggestions) { + originalProviders.push(s.provider); + s.provider = provider; + } + + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + isBestMatch, + suggestedIndex, + telemetryType: provider, + }), + ], + }); + + UrlbarPrefs.clear("bestMatch.enabled"); + UrlbarPrefs.clear("suggest.bestmatch"); + + // Restore the original provider. + for (let s of MerinoTestUtils.server.response.body.suggestions) { + s.provider = originalProviders.shift(); + } +} + +function makeExpectedResult({ + isBestMatch, + suggestedIndex, + dupedHeuristic, + telemetryType = "top_picks", +}) { + let result = { + isBestMatch, + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + dupedHeuristic, + telemetryType, + title: "title", + url: SUGGESTION_URL, + displayUrl: SUGGESTION_URL_DISPLAY, + icon: "icon", + isSponsored: false, + source: "merino", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }; + if (typeof dupedHeuristic == "boolean") { + result.payload.dupedHeuristic = dupedHeuristic; + } + return result; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js new file mode 100644 index 0000000000..598f0d89a5 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js @@ -0,0 +1,217 @@ +/* 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/. */ + +// Tests the chunking feature of `RemoteSettingsClient.#addResults()`. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SuggestionsMap: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +// This overrides `SuggestionsMap.chunkSize`. Testing the actual value can make +// the test run too long. This is OK because the correctness of the chunking +// behavior doesn't depend on the chunk size. +const TEST_CHUNK_SIZE = 100; + +add_task(async function init() { + // Sanity check the actual `chunkSize` value. + Assert.equal( + typeof SuggestionsMap.chunkSize, + "number", + "Sanity check: chunkSize is a number" + ); + Assert.greater(SuggestionsMap.chunkSize, 0, "Sanity check: chunkSize > 0"); + + // Set our test value. + SuggestionsMap.chunkSize = TEST_CHUNK_SIZE; +}); + +// Tests many suggestions with one keyword each. +add_task(async function chunking_singleKeyword() { + let suggestionCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let count of suggestionCounts) { + await doChunkingTest(count, 1); + } +}); + +// Tests a small number of suggestions with many keywords each. +add_task(async function chunking_manyKeywords() { + let keywordCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let suggestionCount = 1; suggestionCount <= 3; suggestionCount++) { + for (let keywordCount of keywordCounts) { + await doChunkingTest(suggestionCount, keywordCount); + } + } +}); + +async function doChunkingTest(suggestionCount, keywordCountPerSuggestion) { + info( + "Running chunking test: " + + JSON.stringify({ suggestionCount, keywordCountPerSuggestion }) + ); + + // Create `suggestionCount` suggestions, each with `keywordCountPerSuggestion` + // keywords. + let suggestions = []; + for (let i = 0; i < suggestionCount; i++) { + let keywords = []; + for (let k = 0; k < keywordCountPerSuggestion; k++) { + keywords.push(`keyword-${i}-${k}`); + } + suggestions.push({ + keywords, + id: i, + url: "http://example.com/" + i, + title: "Suggestion " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }); + } + + // Add the suggestions. + let map = new SuggestionsMap(); + await map.add(suggestions); + + // Make sure all keyword-suggestion pairs have been added. + for (let i = 0; i < suggestionCount; i++) { + for (let k = 0; k < keywordCountPerSuggestion; k++) { + let keyword = `keyword-${i}-${k}`; + + // Check the map. Logging all assertions takes a ton of time and makes the + // test run much longer than it otherwise would, especially if `chunkSize` + // is large, so only log failing assertions. + let actualSuggestions = map.get(keyword); + if (!ObjectUtils.deepEqual(actualSuggestions, [suggestions[i]])) { + Assert.deepEqual( + actualSuggestions, + [suggestions[i]], + `Suggestion ${i} is present for keyword ${keyword}` + ); + } + } + } +} + +add_task(async function duplicateKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [0], + b: [0, 1], + c: [0, 1, 2], + d: [1, 2], + e: [2], + f: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); + +add_task(async function mapKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [], + b: [], + c: [], + d: [], + e: [], + f: [], + ax: [0], + bx: [0, 1], + cx: [0, 1, 2], + dx: [1, 2], + ex: [2], + fx: [3], + fy: [3], + fz: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions, keyword => { + if (keyword == "f") { + return [keyword + "x", keyword + "y", keyword + "z"]; + } + return [keyword + "x"]; + }); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js new file mode 100644 index 0000000000..04fbc0b9d8 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js @@ -0,0 +1,1394 @@ +/* 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/. */ + +// Tests the quick suggest weather feature. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER"; + +const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils; + +add_task(async function init() { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + UrlbarPrefs.set("quicksuggest.enabled", true); + + await MerinoTestUtils.initWeather(); + + // Give this a small value so it doesn't delay the test too long. Choose a + // value that's unlikely to be used anywhere else in the test so that when + // `lastFetchTimeMs` is expected to be `fetchDelayAfterComingOnlineMs`, we can + // be sure the value actually came from `fetchDelayAfterComingOnlineMs`. + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs = 53; +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// feature gate pref. +add_task(async function disableAndEnable_featureGate() { + await doBasicDisableAndEnableTest("weather.featureGate"); +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// suggest pref. +add_task(async function disableAndEnable_suggestPref() { + await doBasicDisableAndEnableTest("suggest.weather"); +}); + +async function doBasicDisableAndEnableTest(pref) { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set(pref, false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + info("Re-enable the feature"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set(pref, true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeExpectedResult()], + }); +} + +add_task(async function keywordsNotDefined() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Set RS data without any keywords. Fetching should immediately stop. + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: {}, + }, + ]); + assertDisabled({ + message: "After setting RS data without keywords", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Set keywords. Fetching should immediately start. + info("Setting keywords"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + assertEnabled({ + message: "Immediately after setting keywords", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeExpectedResult()], + }); +}); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// +// At this point, the fetch from step 2 will remain ongoing but once it finishes +// it should be discarded since the feature is disabled. +add_task(async function disableAndEnable_immediate1() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Wait for the fetch to finish. + await fetchPromise; + + // The fetched suggestion should be discarded and the feature should remain + // uninitialized. + assertDisabled({ + message: "After awaiting fetch", + pendingFetchCount: 0, + }); + + // Clean up by re-enabling the feature for the remaining tasks. + fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + await fetchPromise; + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// 4. Enable again +// +// At this point, the fetches from steps 2 and 4 will remain ongoing. The fetch +// from step 2 should be discarded. +add_task(async function disableAndEnable_immediate2() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Re-enable it. A new fetch should start, so now there will be two pending + // fetches. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: false, + pendingFetchCount: 2, + }); + + // Wait for both fetches to finish. + await fetchPromise; + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A fetch that doesn't return a suggestion should cause the last-fetched +// suggestion to be discarded. +add_task(async function noSuggestion() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + let { suggestions } = MerinoTestUtils.server.response.body; + MerinoTestUtils.server.response.body.suggestions = []; + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + MerinoTestUtils.server.response.body.suggestions = suggestions; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A network error should cause the last-fetched suggestion to be discarded. +add_task(async function networkError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Set the weather fetch timeout high enough that the network error exception + // will happen first. See `MerinoTestUtils.withNetworkError()`. + QuickSuggest.weather._test_setTimeoutMs(10000); + + await MerinoTestUtils.server.withNetworkError(async () => { + await QuickSuggest.weather._test_fetch(); + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "network_error", + latencyRecorded: false, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// An HTTP error should cause the last-fetched suggestion to be discarded. +add_task(async function httpError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + MerinoTestUtils.server.response = { status: 500 }; + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "http_error", + "The request failed with an HTTP error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "http_error", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [WEATHER_SUGGESTION]; + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A fetch that doesn't return a suggestion due to a client timeout should cause +// the last-fetched suggestion to be discarded. +add_task(async function clientTimeout() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Make the server return a delayed response so the Merino client times out + // waiting for it. + MerinoTestUtils.server.response.delay = 400; + + // Make the client time out immediately. + QuickSuggest.weather._test_setTimeoutMs(1); + + // Set up a promise that will be resolved when the client finally receives the + // response. + let responsePromise = QuickSuggest.weather._test_merino.waitForNextResponse(); + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "timeout", + "The request timed out" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Await the response. + await responsePromise; + + // 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: QuickSuggest.weather._test_merino, + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + delete MerinoTestUtils.server.response.delay; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Locale task for when this test runs on an en-US OS. +add_task(async function locale_enUS() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale == "en-US", + osUnit: "f", + unitsByLocale: { + "en-US": "f", + // When the app's locale is set to any en-* locale, F will be used because + // `regionalPrefsLocales` will prefer the en-US OS locale. + "en-CA": "f", + "en-GB": "f", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-US English OS. +add_task(async function locale_nonUSEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale.startsWith("en") && osLocale != "en-US", + osUnit: "c", + unitsByLocale: { + // When the app's locale is set to en-US, C will be used because + // `regionalPrefsLocales` will prefer the non-US English OS locale. + "en-US": "c", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-English OS. +add_task(async function locale_nonEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => !osLocale.startsWith("en"), + osUnit: "c", + unitsByLocale: { + "en-US": "f", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +/** + * Testing locales is tricky due to the weather feature's use of + * `Services.locale.regionalPrefsLocales`. By default `regionalPrefsLocales` + * prefers the OS locale if its language is the same as the app locale's + * language; otherwise it prefers the app locale. For example, assuming the OS + * locale is en-CA, then if the app locale is en-US it will prefer en-CA since + * both are English, but if the app locale is de it will prefer de. If the pref + * `intl.regional_prefs.use_os_locales` is set, then the OS locale is always + * preferred. + * + * This function tests a given set of locales with and without + * `intl.regional_prefs.use_os_locales` set. + * + * @param {object} options + * Options + * @param {Function} options.shouldRunTask + * Called with the OS locale. Should return true if the function should run. + * Use this to skip tasks that don't target a desired OS locale. + * @param {string} options.osUnit + * The expected "c" or "f" unit for the OS locale. + * @param {object} options.unitsByLocale + * The expected "c" or "f" unit when the app's locale is set to particular + * locales. This should be an object that maps locales to expected units. For + * each locale in the object, the app's locale is set to that locale and the + * actual unit is expected to be the unit in the object. + */ +async function doLocaleTest({ shouldRunTask, osUnit, unitsByLocale }) { + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + let osLocale = Services.locale.regionalPrefsLocales[0]; + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + + if (!shouldRunTask(osLocale)) { + info("Skipping task, should not run for this OS locale"); + return; + } + + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Sanity check initial locale info. + Assert.equal( + Services.locale.appLocaleAsBCP47, + "en-US", + "Initial app locale should be en-US" + ); + Assert.ok( + !Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales"), + "intl.regional_prefs.use_os_locales should be false initially" + ); + + // Check locales. + for (let [locale, temperatureUnit] of Object.entries(unitsByLocale)) { + await QuickSuggestTestUtils.withLocales([locale], async () => { + info("Checking locale: " + locale); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ temperatureUnit })], + }); + + info( + "Checking locale with intl.regional_prefs.use_os_locales: " + locale + ); + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ temperatureUnit: osUnit })], + }); + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + }); + } +} + +// Blocks a result and makes sure the weather pref is disabled. +add_task(async function block() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + Assert.ok( + UrlbarPrefs.get("suggest.weather"), + "Sanity check: suggest.weather is true initially" + ); + + // Do a search so we can get an actual result. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeExpectedResult()], + }); + + // Block the result. + UrlbarProviderWeather.onEngagement(false, "engagement", context, { + result: context.results[0], + selType: "dismiss", + selIndex: context.results[0].rowIndex, + }); + Assert.ok( + !UrlbarPrefs.get("suggest.weather"), + "suggest.weather is false after blocking the result" + ); + + // Do a second search. Nothing should be returned. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Re-enable the pref and clean up. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("suggest.weather", true); + await fetchPromise; + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Simulates wake 100ms before the start of the next fetch period. A new fetch +// should not start. +add_task(async function wakeBeforeNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs - 100, + shouldFetchOnWake: false, + fetchTimerMsOnWake: 100, + }); +}); + +// Simulates wake 100ms after the start of the next fetch period. A new fetch +// should start. +add_task(async function wakeAfterNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +// Simulates wake after many fetch periods + 100ms. A new fetch should start. +add_task(async function wakeAfterManyFetchPeriods() { + await doWakeTest({ + sleepIntervalMs: 100 * QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +async function doWakeTest({ + sleepIntervalMs, + shouldFetchOnWake, + fetchTimerMsOnWake, +}) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start the first fetch period. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // Advance the clock and simulate wake. + info("Sending wake notification"); + let nowOnWake = nowOnStart + sleepIntervalMs; + dateNowStub.returns(nowOnWake); + QuickSuggest.weather.observe(null, "wake_notification", ""); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After wake, next fetch should not have immediately started" + ); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "After wake, last fetch time should be unchanged" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "After wake, the timer should exist (be non-zero)" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "After wake, a new timer should have been created" + ); + + if (shouldFetchOnWake) { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "After wake, timer period should be fetchDelayAfterComingOnlineMs" + ); + } else { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + fetchTimerMsOnWake, + "After wake, timer period should be the remaining interval" + ); + } + + // Wait for the fetch. If the wake didn't trigger it, then the caller should + // have passed in a `sleepIntervalMs` that will make it start soon. + info("Waiting for fetch after wake"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "After post-wake fetch, timer period should remain full fetch interval" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After post-wake fetch, no more fetches should be pending" + ); + + dateNowStub.restore(); +} + +// When network:link-status-changed is observed and the suggestion is non-null, +// a fetch should not start. +add_task(async function networkLinkStatusChanged_nonNull() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:link-status-changed", + dataValues: [ + "down", + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is +// non-null, a fetch should not start. +add_task(async function networkOfflineStatusChanged_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:offline-status-changed", + dataValues: ["offline", "online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is non-null, +// a fetch should not start. +add_task(async function captivePortalLoginSuccess_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "captive-portal-login-success", + dataValues: [""], + }); +}); + +async function doOnlineTestWithSuggestion({ topic, dataValues }) { + info("Starting fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.ok( + QuickSuggest.weather.suggestion, + "Suggestion should have been fetched" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + for (let data of dataValues) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } +} + +// When network:link-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_task(async function networkLinkStatusChanged_null() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:link-status-changed", + offlineData: "down", + otherDataValues: [ + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_task(async function networkOfflineStatusChanged_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:offline-status-changed", + offlineData: "offline", + otherDataValues: ["online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is null, a +// fetch should start. +add_task(async function captivePortalLoginSuccess_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "captive-portal-login-success", + otherDataValues: [""], + }); +}); + +async function doOnlineTestWithNullSuggestion({ + topic, + otherDataValues, + offlineData = "", +}) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // First, send the notification with the offline data value. Nothing should + // happen. + if (offlineData) { + info("Sending notification: " + JSON.stringify({ topic, offlineData })); + QuickSuggest.weather.observe(null, topic, offlineData); + + Assert.ok( + !QuickSuggest.weather.suggestion, + "Suggestion should remain null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } + + // Now send it with all other data values. Fetches should be triggered. + for (let data of otherDataValues) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started yet" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "Timer ms should be fetchDelayAfterComingOnlineMs" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + + info("Waiting for fetch after notification"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + } +} + +// When many online notifications are received at once, only one fetch should +// start. +add_task(async function manyOnlineNotifications() { + await doManyNotificationsTest([ + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +// When wake and online notifications are received at once, only one fetch +// should start. +add_task(async function wakeAndOnlineNotifications() { + await doManyNotificationsTest([ + ["wake_notification", ""], + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +async function doManyNotificationsTest(notifications) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start a first fetch period so that after we send the notifications below + // the last fetch time will be in the past. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + + // Now advance the clock by many fetch intervals. + let nowOnWake = nowOnStart + 100 * QuickSuggest.weather._test_fetchIntervalMs; + dateNowStub.returns(nowOnWake); + + // Set the suggestion to null so online notifications will trigger a fetch. + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Clear the server's list of received requests. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [ + MerinoTestUtils.WEATHER_SUGGESTION, + ]; + + // Send the notifications. + for (let [topic, data] of notifications) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + } + + info("Waiting for fetch after notifications"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + Assert.equal( + MerinoTestUtils.server.requests.length, + 1, + "Merino should have received only one request" + ); + + dateNowStub.restore(); +} + +// Fetching when a VPN is detected should set the suggestion to null, and +// turning off the VPN should trigger a re-fetch. +add_task(async function vpn() { + // Register a mock object that implements nsINetworkLinkService. + let mockLinkService = { + isLinkUp: true, + linkStatusKnown: true, + linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI, + networkID: "abcd", + dnsSuffixList: [], + platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED, + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + }; + let networkLinkServiceCID = MockRegistrar.register( + "@mozilla.org/network/network-link-service;1", + mockLinkService + ); + QuickSuggest.weather._test_linkService = mockLinkService; + + // At this point no VPN is detected, so a fetch should complete successfully. + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should exist"); + + // Modify the mock link service to indicate a VPN is detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.VPN_DETECTED; + + // Now a fetch should set the suggestion to null. + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Set `weather.ignoreVPN` and fetch again. It should complete successfully. + UrlbarPrefs.set("weather.ignoreVPN", true); + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + // Clear the pref and fetch again. It should set the suggestion back to null. + UrlbarPrefs.clear("weather.ignoreVPN"); + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Simulate the link status changing. Since the mock link service still + // indicates a VPN is detected, the suggestion should remain null. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should remain null"); + + // Modify the mock link service to indicate a VPN is no longer detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NONE_DETECTED; + + // Simulate the link status changing again. The suggestion should be fetched. + fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + MockRegistrar.unregister(networkLinkServiceCID); + delete QuickSuggest.weather._test_linkService; +}); + +// When a Nimbus experiment is installed, it should override the remote settings +// config. +add_task(async function nimbusOverride() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Verify a search works as expected with the default remote settings config. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Install an experiment with a different keyword and min length. + let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: ["nimbusoverride"], + weatherKeywordsMinimumLength: "nimbus".length, + }); + + // The usual default keyword shouldn't match. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + + // The new keyword from Nimbus should match. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Uninstall the experiment. + await nimbusCleanup(); + + // The usual default keyword should match again. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // The keywords from Nimbus shouldn't match anymore. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); +}); + +function assertEnabled({ message, hasSuggestion, pendingFetchCount }) { + info("Asserting feature is enabled"); + if (message) { + info(message); + } + + Assert.equal( + !!QuickSuggest.weather.suggestion, + hasSuggestion, + "Suggestion is null or non-null as expected" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} + +function assertDisabled({ message, pendingFetchCount }) { + info("Asserting feature is disabled"); + if (message) { + info(message); + } + + Assert.strictEqual( + QuickSuggest.weather.suggestion, + null, + "Suggestion is null" + ); + Assert.strictEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is zero" + ); + Assert.strictEqual( + QuickSuggest.weather._test_merino, + null, + "Merino client is null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} + +function makeExpectedResult({ + suggestedIndex = 1, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + return { + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + temperatureUnit, + url: WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + merinoProvider: "accuweather", + dynamicType: "weather", + city: WEATHER_SUGGESTION.city_name, + temperature: + WEATHER_SUGGESTION.current_conditions.temperature[temperatureUnit], + currentConditions: WEATHER_SUGGESTION.current_conditions.summary, + forecast: WEATHER_SUGGESTION.forecast.summary, + high: WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js new file mode 100644 index 0000000000..b62a243fb0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js @@ -0,0 +1,1395 @@ +/* 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/. */ + +// Tests the keywords and zero-prefix behavior of quick suggest weather. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils; + +add_task(async function init() { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + UrlbarPrefs.set("quicksuggest.enabled", true); + await MerinoTestUtils.initWeather(); +}); + +// * Settings data: none +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_task(async function () { + await doKeywordsTest({ + desc: "No data", + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_task(async function () { + await doKeywordsTest({ + desc: "Empty settings", + settingsData: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +add_task(async function () { + await doKeywordsTest({ + desc: "Settings only, keywords only", + settingsData: { + keywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +add_task(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: use settings data +add_task(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: empty +// * Min keyword length pref: none +// * Expected: no suggestion +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: empty; Nimbus: empty", + settingsData: {}, + nimbusValues: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length = 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 0, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0; pref exists", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0; pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: full keywords +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length = 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: use Nimbus keywords and min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0; pref exists", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// When `weatherKeywords` is non-null and `weatherKeywordsMinimumLength` is +// larger than the length of all keywords, the suggestion should not be +// triggered. +add_task(async function minLength_large() { + await doKeywordsTest({ + desc: "Large min length", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 999, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// Leading and trailing spaces should be ignored. +add_task(async function leadingAndTrailingSpaces() { + await doKeywordsTest({ + nimbusValues: { + weatherKeywords: ["weather"], + weatherKeywordsMinimumLength: 3, + }, + tests: { + " wea": true, + " wea": true, + "wea ": true, + "wea ": true, + " wea ": true, + " weat": true, + " weat": true, + "weat ": true, + "weat ": true, + " weat ": true, + }, + }); +}); + +add_task(async function caseInsensitive() { + await doKeywordsTest({ + desc: "Case insensitive", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + tests: { + wea: true, + WEA: true, + Wea: true, + WeA: true, + WEATHER: true, + Weather: true, + WeAtHeR: true, + }, + }); +}); + +async function doKeywordsTest({ + desc, + tests, + nimbusValues = null, + settingsData = null, + minKeywordLength = undefined, +}) { + info("Doing keywords test: " + desc); + info(JSON.stringify({ nimbusValues, settingsData, minKeywordLength })); + + // If a suggestion hasn't already been fetched and the data contains keywords, + // a fetch will start. Wait for it to finish below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: settingsData, + }, + ]); + + if (minKeywordLength) { + UrlbarPrefs.set("weather.minKeywordLength", minKeywordLength); + } + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + for (let [searchString, expected] of Object.entries(tests)) { + info( + "Doing search: " + + JSON.stringify({ + searchString, + expected, + }) + ); + + let suggestedIndex = searchString ? 1 : 0; + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult({ suggestedIndex })] : [], + }); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +// When a sponsored quick suggest result matches the same keyword as the weather +// result, the weather result should be shown and the quick suggest result +// should not be shown. +add_task(async function matchingQuickSuggest_sponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.sponsored", true); +}); + +// When a non-sponsored quick suggest result matches the same keyword as the +// weather result, the weather result should be shown and the quick suggest +// result should not be shown. +add_task(async function matchingQuickSuggest_nonsponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.nonsponsored", false); +}); + +async function doMatchingQuickSuggestTest(pref, isSponsored) { + let keyword = "test"; + let iab_category = isSponsored ? "22 - Shopping" : "5 - Education"; + + // Add a remote settings result to quick suggest. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "data", + attachment: [ + { + id: 1, + url: "http://example.com/", + title: "Suggestion", + keywords: [keyword], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category, + }, + ], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + // First do a search to verify the quick suggest result matches the keyword. + info("Doing first search for quick suggest result"); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [ + { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: keyword, + title: "Suggestion", + url: "http://example.com/", + displayUrl: "http://example.com", + originalUrl: "http://example.com/", + icon: null, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: iab_category, + isSponsored, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }, + ], + }); + + // Set up the keyword for the weather suggestion and do a second search to + // verify only the weather result matches. + info("Doing second search for weather suggestion"); + let cleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: [keyword], + weatherKeywordsMinimumLength: 1, + }); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ suggestedIndex: 1 })], + }); + await cleanup(); + + UrlbarPrefs.clear(pref); +} + +add_task(async function () { + await doIncrementTest({ + desc: "Settings only", + setup: { + settingsData: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + }, + }, + ], + }); +}); + +add_task(async function () { + await doIncrementTest({ + desc: "Settings only with cap", + setup: { + settingsData: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + min_keyword_length_cap: 6, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_task(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus", + setup: { + settingsData: { + keywords: ["weather"], + min_keyword_length: 5, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_task(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus with cap in Nimbus", + setup: { + settingsData: { + keywords: ["weather"], + min_keyword_length: 5, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + weatherKeywordsMinimumLengthCap: 6, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +async function doIncrementTest({ desc, setup, tests }) { + info("Doing increment test: " + desc); + info(JSON.stringify({ setup })); + + let { nimbusValues, settingsData } = setup; + + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: settingsData, + }, + ]); + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + for (let { minKeywordLength, canIncrement, searches } of tests) { + info( + "Doing increment test case: " + + JSON.stringify({ + minKeywordLength, + canIncrement, + }) + ); + + Assert.equal( + QuickSuggest.weather.minKeywordLength, + minKeywordLength, + "minKeywordLength should be correct" + ); + Assert.equal( + QuickSuggest.weather.canIncrementMinKeywordLength, + canIncrement, + "canIncrement should be correct" + ); + + for (let [searchString, expected] of Object.entries(searches)) { + Assert.equal( + QuickSuggest.weather.keywords.has(searchString), + expected, + "Keyword should be present/absent as expected: " + searchString + ); + + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult({ suggestedIndex: 1 })] : [], + }); + } + + QuickSuggest.weather.incrementMinKeywordLength(); + info( + "Incremented min keyword length, new value is: " + + QuickSuggest.weather.minKeywordLength + ); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +function makeExpectedResult({ + suggestedIndex = 0, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + return { + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + temperatureUnit, + url: WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + merinoProvider: "accuweather", + dynamicType: "weather", + city: WEATHER_SUGGESTION.city_name, + temperature: + WEATHER_SUGGESTION.current_conditions.temperature[temperatureUnit], + currentConditions: WEATHER_SUGGESTION.current_conditions.summary, + forecast: WEATHER_SUGGESTION.forecast.summary, + high: WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; +} + +function assertFetchingStarted() { + info("Asserting fetching has started"); + + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 1, + "Expected pending fetch count" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini new file mode 100644 index 0000000000..45a17687ae --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini @@ -0,0 +1,23 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +head = ../../unit/head.js head.js +firefox-appdir = browser + +[test_merinoClient.js] +[test_merinoClient_sessions.js] +[test_quicksuggest.js] +[test_quicksuggest_addons.js] +[test_quicksuggest_bestMatch.js] +[test_quicksuggest_dynamicWikipedia.js] +[test_quicksuggest_impressionCaps.js] +[test_quicksuggest_merino.js] +[test_quicksuggest_merinoSessions.js] +[test_quicksuggest_migrate_v1.js] +[test_quicksuggest_migrate_v2.js] +[test_quicksuggest_nonUniqueKeywords.js] +[test_quicksuggest_offlineDefault.js] +[test_quicksuggest_positionInSuggestions.js] +[test_quicksuggest_topPicks.js] +[test_suggestionsMap.js] +[test_weather.js] +[test_weather_keywords.js] diff --git a/browser/components/urlbar/tests/unit/data/engine.xml b/browser/components/urlbar/tests/unit/data/engine.xml new file mode 100644 index 0000000000..61d776655f --- /dev/null +++ b/browser/components/urlbar/tests/unit/data/engine.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>engine.xml</ShortName> +<Description>A test search engine</Description> +<InputEncoding>UTF-8</InputEncoding> +<Url type="text/html" method="GET" template="http://www.example.com/"> + <Param name="q" value="{searchTerms}"/> +</Url> +<SearchForm>http://www.example.com/</SearchForm> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/unit/head.js b/browser/components/urlbar/tests/unit/head.js new file mode 100644 index 0000000000..8f15b0280e --- /dev/null +++ b/browser/components/urlbar/tests/unit/head.js @@ -0,0 +1,1127 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var { UrlbarMuxer, UrlbarProvider, UrlbarQueryContext, UrlbarUtils } = + ChromeUtils.importESModule("resource:///modules/UrlbarUtils.sys.mjs"); + +ChromeUtils.defineESModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarInput: "resource:///modules/UrlbarInput.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", +}); + +XPCOMUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => { + const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/QuickSuggestTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "MerinoTestUtils", () => { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +SearchTestUtils.init(this); +AddonTestUtils.init(this, false); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const SUGGESTIONS_ENGINE_NAME = "Suggestions"; +const TAIL_SUGGESTIONS_ENGINE_NAME = "Tail Suggestions"; + +/** + * Gets the database connection. If the Places connection is invalid it will + * try to create a new connection. + * + * @param [optional] aForceNewConnection + * Forces creation of a new connection to the database. When a + * connection is asyncClosed it cannot anymore schedule async statements, + * though connectionReady will keep returning true (Bug 726990). + * + * @returns The database connection or null if unable to get one. + */ +var gDBConn; +function DBConn(aForceNewConnection) { + if (!aForceNewConnection) { + let db = PlacesUtils.history.DBConnection; + if (db.connectionReady) { + return db; + } + } + + // If the Places database connection has been closed, create a new connection. + if (!gDBConn || aForceNewConnection) { + let file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("places.sqlite"); + let dbConn = (gDBConn = Services.storage.openDatabase(file)); + + TestUtils.topicObserved("profile-before-change").then(() => + dbConn.asyncClose() + ); + } + + return gDBConn.connectionReady ? gDBConn : null; +} + +/** + * @param {string} searchString The search string to insert into the context. + * @param {object} properties Overrides for the default values. + * @returns {UrlbarQueryContext} Creates a dummy query context with pre-filled + * required options. + */ +function createContext(searchString = "foo", properties = {}) { + info(`Creating new queryContext with searchString: ${searchString}`); + let context = new UrlbarQueryContext( + Object.assign( + { + allowAutofill: UrlbarPrefs.get("autoFill"), + isPrivate: true, + maxResults: UrlbarPrefs.get("maxRichResults"), + searchString, + }, + properties + ) + ); + context.view = { + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + acknowledgeDismissal() {}, + }; + UrlbarTokenizer.tokenize(context); + return context; +} + +/** + * Waits for the given notification from the supplied controller. + * + * @param {UrlbarController} controller The controller to wait for a response from. + * @param {string} notification The name of the notification to wait for. + * @param {boolean} expected Wether the notification is expected. + * @returns {Promise} A promise that is resolved with the arguments supplied to + * the notification. + */ +function promiseControllerNotification( + controller, + notification, + expected = true +) { + return new Promise((resolve, reject) => { + let proxifiedObserver = new Proxy( + {}, + { + get: (target, name) => { + if (name == notification) { + return (...args) => { + controller.removeQueryListener(proxifiedObserver); + if (expected) { + resolve(args); + } else { + reject(); + } + }; + } + return () => false; + }, + } + ); + controller.addQueryListener(proxifiedObserver); + }); +} + +/** + * A basic test provider, returning all the provided matches. + */ +class TestProvider extends UrlbarTestUtils.TestProvider { + isActive(context) { + Assert.ok(context, "context is passed-in"); + return true; + } + getPriority(context) { + Assert.ok(context, "context is passed-in"); + return 0; + } + async startQuery(context, add) { + Assert.ok(context, "context is passed-in"); + Assert.equal(typeof add, "function", "add is a callback"); + this._context = context; + for (const result of this._results) { + add(this, result); + } + } + cancelQuery(context) { + // If the query was created but didn't run, this._context will be undefined. + if (this._context) { + Assert.equal(this._context, context, "cancelQuery: context is the same"); + } + if (this._onCancel) { + this._onCancel(); + } + } +} + +function convertToUtf8(str) { + return String.fromCharCode(...new TextEncoder().encode(str)); +} + +/** + * Helper function to clear the existing providers and register a basic provider + * that returns only the results given. + * + * @param {Array} results The results for the provider to return. + * @param {Function} [onCancel] Optional, called when the query provider + * receives a cancel instruction. + * @param {UrlbarUtils.PROVIDER_TYPE} type The provider type. + * @param {string} [name] Optional, use as the provider name. + * If none, a default name is chosen. + * @returns {UrlbarProvider} The provider + */ +function registerBasicTestProvider(results = [], onCancel, type, name) { + let provider = new TestProvider({ results, onCancel, type, name }); + UrlbarProvidersManager.registerProvider(provider); + registerCleanupFunction(() => + UrlbarProvidersManager.unregisterProvider(provider) + ); + return provider; +} + +// Creates an HTTP server for the test. +function makeTestServer(port = -1) { + let httpServer = new HttpServer(); + httpServer.start(port); + registerCleanupFunction(() => httpServer.stop(() => {})); + return httpServer; +} + +/** + * Sets up a search engine that provides some suggestions by appending strings + * onto the search query. + * + * @param {Function} suggestionsFn + * A function that returns an array of suggestion strings given a + * search string. If not given, a default function is used. + * @returns {nsISearchEngine} The new engine. + */ +async function addTestSuggestionsEngine(suggestionsFn = null) { + // This port number should match the number in engine-suggestions.xml. + let server = makeTestServer(); + server.registerPathHandler("/suggest", (req, resp) => { + let params = new URLSearchParams(req.queryString); + let searchStr = params.get("q"); + let suggestions = suggestionsFn + ? suggestionsFn(searchStr) + : [searchStr].concat(["foo", "bar"].map(s => searchStr + " " + s)); + let data = [searchStr, suggestions]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); + }); + await SearchTestUtils.installSearchExtension({ + name: SUGGESTIONS_ENGINE_NAME, + search_url: `http://localhost:${server.identity.primaryPort}/search`, + suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + // test_search_suggestions_aliases.js uses the search form. + search_form: `http://localhost:${server.identity.primaryPort}/search?q={searchTerms}`, + }); + let engine = Services.search.getEngineByName("Suggestions"); + return engine; +} + +/** + * Sets up a search engine that provides some tail suggestions by creating an + * array that mimics Google's tail suggestion responses. + * + * @param {Function} suggestionsFn + * A function that returns an array that mimics Google's tail suggestion + * responses. See bug 1626897. + * NOTE: Consumers specifying suggestionsFn must include searchStr as a + * part of the array returned by suggestionsFn. + * @returns {nsISearchEngine} The new engine. + */ +async function addTestTailSuggestionsEngine(suggestionsFn = null) { + // This port number should match the number in engine-tail-suggestions.xml. + let server = makeTestServer(); + server.registerPathHandler("/suggest", (req, resp) => { + let params = new URLSearchParams(req.queryString); + let searchStr = params.get("q"); + let suggestions = suggestionsFn + ? suggestionsFn(searchStr) + : [ + "what time is it in t", + ["what is the time today texas"].concat( + ["toronto", "tunisia"].map(s => searchStr + s.slice(1)) + ), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [{}].concat( + ["toronto", "tunisia"].map(s => ({ + mp: "… ", + t: s, + })) + ), + }, + ]; + let data = suggestions; + let jsonString = JSON.stringify(data); + // This script must be evaluated as UTF-8 for this to write out the bytes of + // the string in UTF-8. If it's evaluated as Latin-1, the written bytes + // will be the result of UTF-8-encoding the result-string *twice*, which + // will break the "… " match prefixes. + let stringOfUtf8Bytes = convertToUtf8(jsonString); + resp.setHeader("Content-Type", "application/json", false); + resp.write(stringOfUtf8Bytes); + }); + await SearchTestUtils.installSearchExtension({ + name: TAIL_SUGGESTIONS_ENGINE_NAME, + search_url: `http://localhost:${server.identity.primaryPort}/search`, + suggest_url: `http://localhost:${server.identity.primaryPort}/suggest`, + suggest_url_get_params: "?q={searchTerms}", + }); + let engine = Services.search.getEngineByName("Tail Suggestions"); + return engine; +} + +async function addOpenPages(uri, count = 1, userContextId = 0) { + for (let i = 0; i < count; i++) { + await UrlbarProviderOpenTabs.registerOpenTab( + uri.spec, + userContextId, + false + ); + } +} + +async function removeOpenPages(aUri, aCount = 1, aUserContextId = 0) { + for (let i = 0; i < aCount; i++) { + await UrlbarProviderOpenTabs.unregisterOpenTab( + aUri.spec, + aUserContextId, + false + ); + } +} + +/** + * Helper for tests that generate search results but aren't interested in + * suggestions, such as autofill tests. Installs a test engine and disables + * suggestions. + */ +function testEngine_setup() { + add_task(async function setup() { + await cleanupPlaces(); + let engine = await addTestSuggestionsEngine(); + let oldDefaultEngine = await Services.search.getDefault(); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + }); +} + +async function cleanupPlaces() { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +/** + * Creates a UrlbarResult for a bookmark result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.title + * The page title. + * @param {string} options.uri + * The page URI. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @param {Array} [options.tags] + * An array of string tags. Defaults to an empty array. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {number} [options.source] + * Where the results should be sourced from. See {@link UrlbarUtils.RESULT_SOURCE}. + * @returns {UrlbarResult} + */ +function makeBookmarkResult( + queryContext, + { + title, + uri, + iconUri, + tags = [], + heuristic = false, + source = UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + } +) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: [typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`], + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED], + }) + ); + + result.heuristic = heuristic; + return result; +} + +/** + * Creates a UrlbarResult for a form history result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.suggestion + * The form history suggestion. + * @param {string} options.engineName + * The name of the engine that will do the search when the result is picked. + * @returns {UrlbarResult} + */ +function makeFormHistoryResult(queryContext, { suggestion, engineName }) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engineName, + suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: suggestion.toLocaleLowerCase(), + }) + ); +} + +/** + * Creates a UrlbarResult for an omnibox extension result. For more information, + * see the documentation for omnibox.SuggestResult: + * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/omnibox/SuggestResult + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.content + * The string displayed when the result is highlighted. + * @param {string} options.description + * The string displayed in the address bar dropdown. + * @param {string} options.keyword + * The keyword associated with the extension returning the result. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @returns {UrlbarResult} + */ +function makeOmniboxResult( + queryContext, + { content, description, keyword, heuristic = false } +) { + let payload = { + title: [description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [content, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + icon: [UrlbarUtils.ICON.EXTENSION], + }; + if (!heuristic) { + payload.blockL10n = { id: "urlbar-result-menu-dismiss-firefox-suggest" }; + } + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.ADDON, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + result.heuristic = heuristic; + + return result; +} + +/** + * Creates a UrlbarResult for an switch-to-tab result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} [options.title] + * The page title. + * @param {string} [options.iconUri] + * A URI for the page icon. + * @returns {UrlbarResult} + */ +function makeTabSwitchResult(queryContext, { uri, title, iconUri }) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`, + }) + ); +} + +/** + * Creates a UrlbarResult for a keyword search result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} options.keyword + * The page's search keyword. + * @param {string} [options.title] + * The title for the bookmarked keyword page. + * @param {string} [options.iconUri] + * A URI for the engine's icon. + * @param {string} [options.postData] + * The search POST data. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @returns {UrlbarResult} + */ +function makeKeywordSearchResult( + queryContext, + { uri, keyword, title, iconUri, postData, heuristic = false } +) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.KEYWORD, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [title ? title : uri, UrlbarUtils.HIGHLIGHT.TYPED], + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + input: [queryContext.searchString, UrlbarUtils.HIGHLIGHT.TYPED], + postData: postData || null, + icon: typeof iconUri != "undefined" ? iconUri : `page-icon:${uri}`, + }) + ); + + if (heuristic) { + result.heuristic = heuristic; + } + return result; +} + +/** + * Creates a UrlbarResult for a priority search result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} [options.engineName] + * The name of the engine providing the suggestion. Leave blank if there + * is no suggestion. + * @param {string} [options.engineIconUri] + * A URI for the engine's icon. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @returns {UrlbarResult} + */ +function makePrioritySearchResult( + queryContext, + { engineName, engineIconUri, heuristic } +) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: [engineName, UrlbarUtils.HIGHLIGHT.TYPED], + icon: engineIconUri, + }) + ); + + if (heuristic) { + result.heuristic = heuristic; + } + return result; +} + +/** + * Creates a UrlbarResult for a remote tab result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} options.uri + * The page URI. + * @param {string} options.device + * The name of the device that the remote tab comes from. + * @param {string} [options.title] + * The page title. + * @param {number} [options.lastUsed] + * The last time the remote tab was visited, in epoch seconds. Defaults + * to 0. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @returns {UrlbarResult} + */ +function makeRemoteTabResult( + queryContext, + { uri, device, title, iconUri, lastUsed = 0 } +) { + let payload = { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + device: [device, UrlbarUtils.HIGHLIGHT.TYPED], + // Check against undefined so consumers can pass in the empty string. + icon: + typeof iconUri != "undefined" + ? iconUri + : `moz-anno:favicon:page-icon:${uri}`, + lastUsed: lastUsed * 1000, + }; + + // Check against undefined so consumers can pass in the empty string. + if (typeof title != "undefined") { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + payload.title = [uri, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + return result; +} + +/** + * Creates a UrlbarResult for a search result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options + * Options for the result. + * @param {string} [options.suggestion] + * The suggestion offered by the search engine. + * @param {string} [options.tailPrefix] + * The characters placed at the end of a Google "tail" suggestion. See + * {@link https://firefox-source-docs.mozilla.org/browser/urlbar/nontechnical-overview.html#search-suggestions} + * @param {*} [options.tail] + * The details of the URL bar tail + * @param {number} [options.tailOffsetIndex] + * The index of the first character in the tail suggestion that should be + * @param {string} [options.engineName] + * The name of the engine providing the suggestion. Leave blank if there + * is no suggestion. + * @param {string} [options.uri] + * The URI that the search result will navigate to. + * @param {string} [options.query] + * The query that started the search. This overrides + * `queryContext.searchString`. This is useful when the query that will show + * up in the result object will be different from what was typed. For example, + * if a leading restriction token will be used. + * @param {string} [options.alias] + * The alias for the search engine, if the search is an alias search. + * @param {string} [options.engineIconUri] + * A URI for the engine's icon. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {boolean} [options.providesSearchMode] + * Whether search mode is entered when this result is selected. + * @param {string} [options.providerName] + * The name of the provider offering this result. The test suite will not + * check which provider offered a result unless this option is specified. + * @param {boolean} [options.inPrivateWindow] + * If the window to test is a private window. + * @param {boolean} [options.isPrivateEngine] + * If the engine is a private engine. + * @param {number} [options.type] + * The type of the search result. Defaults to UrlbarUtils.RESULT_TYPE.SEARCH. + * @param {number} [options.source] + * The source of the search result. Defaults to UrlbarUtils.RESULT_SOURCE.SEARCH. + * @param {boolean} [options.satisfiesAutofillThreshold] + * If this search should appear in the autofill section of the box + * @param {boolean} [options.trending] + * If the search result is a trending result. `Defaults to false`. + * @param {boolean} [options.isRichSuggestion] + * If the search result is a rich result. `Defaults to false`. + * @returns {UrlbarResult} + */ +function makeSearchResult( + queryContext, + { + suggestion, + tailPrefix, + tail, + tailOffsetIndex, + engineName, + alias, + uri, + query, + engineIconUri, + providesSearchMode, + providerName, + inPrivateWindow, + isPrivateEngine, + heuristic = false, + trending = false, + isRichSuggestion = false, + type = UrlbarUtils.RESULT_TYPE.SEARCH, + source = UrlbarUtils.RESULT_SOURCE.SEARCH, + satisfiesAutofillThreshold = false, + } +) { + // Tail suggestion common cases, handled here to reduce verbosity in tests. + if (tail) { + if (!tailPrefix) { + tailPrefix = "… "; + } + if (!tailOffsetIndex) { + tailOffsetIndex = suggestion.indexOf(tail); + } + } + + let payload = { + engine: [engineName, UrlbarUtils.HIGHLIGHT.TYPED], + suggestion: [suggestion, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailPrefix, + tail: [tail, UrlbarUtils.HIGHLIGHT.SUGGESTED], + tailOffsetIndex, + keyword: [ + alias, + providesSearchMode + ? UrlbarUtils.HIGHLIGHT.TYPED + : UrlbarUtils.HIGHLIGHT.NONE, + ], + // Check against undefined so consumers can pass in the empty string. + query: [ + typeof query != "undefined" ? query : queryContext.trimmedSearchString, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + icon: engineIconUri, + providesSearchMode, + inPrivateWindow, + isPrivateEngine, + }; + + // Passing even an undefined URL in the payload creates a potentially-unwanted + // displayUrl parameter, so we add it only if specified. + if (uri) { + payload.url = uri; + } + if (providerName == "TabToSearch") { + payload.satisfiesAutofillThreshold = satisfiesAutofillThreshold; + if (payload.url.startsWith("www.")) { + payload.url = payload.url.substring(4); + } + payload.isGeneralPurposeEngine = false; + } + + let result = new UrlbarResult( + type, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + if (typeof suggestion == "string") { + result.payload.lowerCaseSuggestion = + result.payload.suggestion.toLocaleLowerCase(); + result.payload.trending = trending; + result.payload.isRichSuggestion = isRichSuggestion; + } + + if (providerName) { + result.providerName = providerName; + } + + result.heuristic = heuristic; + return result; +} + +/** + * Creates a UrlbarResult for a history result. + * + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {object} options Options for the result. + * @param {string} options.title + * The page title. + * @param {string} [options.fallbackTitle] + * The provider has capability to use the actual page title though, + * when the provider can’t get the page title, use this value as the fallback. + * @param {string} options.uri + * The page URI. + * @param {Array} [options.tags] + * An array of string tags. Defaults to an empty array. + * @param {string} [options.iconUri] + * A URI for the page's icon. + * @param {boolean} [options.heuristic] + * True if this is a heuristic result. Defaults to false. + * @param {string} options.providerName + * The name of the provider offering this result. The test suite will not + * check which provider offered a result unless this option is specified. + * @param {number} [options.source] + * The source of the result + * @returns {UrlbarResult} + */ +function makeVisitResult( + queryContext, + { + title, + fallbackTitle, + uri, + iconUri, + providerName, + tags = [], + heuristic = false, + source = UrlbarUtils.RESULT_SOURCE.HISTORY, + } +) { + let payload = { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + }; + + if (title) { + payload.title = [title, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + if (fallbackTitle) { + payload.fallbackTitle = [fallbackTitle, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + if (iconUri) { + payload.icon = iconUri; + } else if ( + iconUri === undefined && + source != UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL + ) { + payload.icon = `page-icon:${uri}`; + } + + if (!heuristic && tags) { + payload.tags = [tags, UrlbarUtils.HIGHLIGHT.TYPED]; + } + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + if (providerName) { + result.providerName = providerName; + } + + result.heuristic = heuristic; + return result; +} + +/** + * Checks that the results returned by a UrlbarController match those in + * the param `matches`. + * + * @param {object} options Options for the check. + * @param {UrlbarQueryContext} options.context + * The context for this query. + * @param {string} [options.incompleteSearch] + * A search will be fired for this string and then be immediately canceled by + * the query in `context`. + * @param {string} [options.autofilled] + * The autofilled value in the first result. + * @param {string} [options.completed] + * The value that would be filled if the autofill result was confirmed. + * Has no effect if `autofilled` is not specified. + * @param {Array} options.matches + * An array of UrlbarResults. + */ +async function check_results({ + context, + incompleteSearch, + autofilled, + completed, + matches = [], +} = {}) { + if (!context) { + return; + } + + // At this point frecency could still be updating due to latest pages + // updates. + // This is not a problem in real life, but autocomplete tests should + // return reliable resultsets, thus we have to wait. + await PlacesTestUtils.promiseAsyncUpdates(); + + const controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + + if (incompleteSearch) { + let incompleteContext = createContext(incompleteSearch, { + isPrivate: context.isPrivate, + }); + controller.startQuery(incompleteContext); + } + await controller.startQuery(context); + + if (autofilled) { + Assert.ok(context.results[0], "There is a first result."); + Assert.ok( + context.results[0].autofill, + "The first result is an autofill result" + ); + Assert.equal( + context.results[0].autofill.value, + autofilled, + "The correct value was autofilled." + ); + if (completed) { + Assert.equal( + context.results[0].payload.url, + completed, + "The completed autofill value is correct." + ); + } + } + if (context.results.length != matches.length) { + info("Actual results: " + JSON.stringify(context.results)); + } + Assert.equal( + context.results.length, + matches.length, + "Found the expected number of results." + ); + + function getPayload(result) { + let payload = {}; + for (let [key, value] of Object.entries(result.payload)) { + if (value !== undefined) { + payload[key] = value; + } + } + return payload; + } + + for (let i = 0; i < matches.length; i++) { + let actual = context.results[i]; + let expected = matches[i]; + info( + `Comparing results at index ${i}:` + + " actual=" + + JSON.stringify(actual) + + " expected=" + + JSON.stringify(expected) + ); + Assert.equal( + actual.type, + expected.type, + `result.type at result index ${i}` + ); + Assert.equal( + actual.source, + expected.source, + `result.source at result index ${i}` + ); + Assert.equal( + actual.heuristic, + expected.heuristic, + `result.heuristic at result index ${i}` + ); + Assert.equal( + !!actual.isBestMatch, + !!expected.isBestMatch, + `result.isBestMatch at result index ${i}` + ); + if (expected.providerName) { + Assert.equal( + actual.providerName, + expected.providerName, + `result.providerName at result index ${i}` + ); + } + if (expected.hasOwnProperty("suggestedIndex")) { + Assert.equal( + actual.suggestedIndex, + expected.suggestedIndex, + `result.suggestedIndex at result index ${i}` + ); + } + + if (expected.payload) { + Assert.deepEqual( + getPayload(actual), + getPayload(expected), + `result.payload at result index ${i}` + ); + } + } +} + +/** + * Returns the frecency of an origin. + * + * @param {string} prefix + * The origin's prefix, e.g., "http://". + * @param {string} aHost + * The origin's host. + * @returns {number} The origin's frecency. + */ +async function getOriginFrecency(prefix, aHost) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + ` + SELECT frecency + FROM moz_origins + WHERE prefix = :prefix AND host = :host + `, + { prefix, host: aHost } + ); + Assert.equal(rows.length, 1); + return rows[0].getResultByIndex(0); +} + +/** + * Returns the origin frecency stats. + * + * @returns {object} + * An object { count, sum, squares }. + */ +async function getOriginFrecencyStats() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute(` + SELECT + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_count'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum'), 0), + IFNULL((SELECT value FROM moz_meta WHERE key = 'origin_frecency_sum_of_squares'), 0) + `); + let count = rows[0].getResultByIndex(0); + let sum = rows[0].getResultByIndex(1); + let squares = rows[0].getResultByIndex(2); + return { count, sum, squares }; +} + +/** + * Returns the origin autofill frecency threshold. + * + * @returns {number} + * The threshold. + */ +async function getOriginAutofillThreshold() { + let { count, sum, squares } = await getOriginFrecencyStats(); + if (!count) { + return 0; + } + if (count == 1) { + return sum; + } + let stddevMultiplier = UrlbarPrefs.get("autoFill.stddevMultiplier"); + return ( + sum / count + + stddevMultiplier * Math.sqrt((squares - (sum * sum) / count) / count) + ); +} + +/** + * Checks that origins appear in a given order in the database. + * + * @param {string} host The "fixed" host, without "www." + * @param {Array} prefixOrder The prefixes (scheme + www.) sorted appropriately. + */ +async function checkOriginsOrder(host, prefixOrder) { + await PlacesUtils.withConnectionWrapper("checkOriginsOrder", async db => { + let prefixes = ( + await db.execute( + `SELECT prefix || iif(instr(host, "www.") = 1, "www.", "") + FROM moz_origins + WHERE host = :host OR host = "www." || :host + ORDER BY ROWID ASC + `, + { host } + ) + ).map(r => r.getResultByIndex(0)); + Assert.deepEqual(prefixes, prefixOrder); + }); +} diff --git a/browser/components/urlbar/tests/unit/test_000_frecency.js b/browser/components/urlbar/tests/unit/test_000_frecency.js new file mode 100644 index 0000000000..cef110963f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_000_frecency.js @@ -0,0 +1,245 @@ +/* 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/. */ + +/* + +Autocomplete Frecency Tests + +- add a visit for each score permutation +- search +- test number of matches +- test each item's location in results + +*/ + +testEngine_setup(); + +try { + var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"].getService( + Ci.nsINavHistoryService + ); +} catch (ex) { + do_throw("Could not get services\n"); +} + +var bucketPrefs = [ + ["firstBucketCutoff", "firstBucketWeight"], + ["secondBucketCutoff", "secondBucketWeight"], + ["thirdBucketCutoff", "thirdBucketWeight"], + ["fourthBucketCutoff", "fourthBucketWeight"], + [null, "defaultBucketWeight"], +]; + +var bonusPrefs = { + embedVisitBonus: PlacesUtils.history.TRANSITION_EMBED, + framedLinkVisitBonus: PlacesUtils.history.TRANSITION_FRAMED_LINK, + linkVisitBonus: PlacesUtils.history.TRANSITION_LINK, + typedVisitBonus: PlacesUtils.history.TRANSITION_TYPED, + bookmarkVisitBonus: PlacesUtils.history.TRANSITION_BOOKMARK, + downloadVisitBonus: PlacesUtils.history.TRANSITION_DOWNLOAD, + permRedirectVisitBonus: PlacesUtils.history.TRANSITION_REDIRECT_PERMANENT, + tempRedirectVisitBonus: PlacesUtils.history.TRANSITION_REDIRECT_TEMPORARY, + reloadVisitBonus: PlacesUtils.history.TRANSITION_RELOAD, +}; + +// create test data +var searchTerm = "frecency"; +var results = []; +var now = Date.now(); +var prefPrefix = "places.frecency."; + +async function task_initializeBucket(bucket) { + let [cutoffName, weightName] = bucket; + // get pref values + let weight = Services.prefs.getIntPref(prefPrefix + weightName, 0); + let cutoff = Services.prefs.getIntPref(prefPrefix + cutoffName, 0); + if (cutoff < 1) { + return; + } + + // generate a date within the cutoff period + let dateInPeriod = (now - (cutoff - 1) * 86400 * 1000) * 1000; + + for (let [bonusName, visitType] of Object.entries(bonusPrefs)) { + let frecency = -1; + let calculatedURI = null; + let matchTitle = ""; + let bonusValue = Services.prefs.getIntPref(prefPrefix + bonusName); + // unvisited (only for first cutoff date bucket) + if ( + bonusName == "unvisitedBookmarkBonus" || + bonusName == "unvisitedTypedBonus" + ) { + if (cutoffName == "firstBucketCutoff") { + let points = Math.ceil((bonusValue / parseFloat(100.0)) * weight); + let visitCount = 1; // bonusName == "unvisitedBookmarkBonus" ? 1 : 0; + frecency = Math.ceil(visitCount * points); + calculatedURI = Services.io.newURI( + "http://" + + searchTerm + + ".com/" + + bonusName + + ":" + + bonusValue + + "/cutoff:" + + cutoff + + "/weight:" + + weight + + "/frecency:" + + frecency + ); + if (bonusName == "unvisitedBookmarkBonus") { + matchTitle = searchTerm + "UnvisitedBookmark"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: calculatedURI, + title: matchTitle, + }); + } else { + matchTitle = searchTerm + "UnvisitedTyped"; + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + title: matchTitle, + transition: visitType, + visitDate: now, + }); + histsvc.markPageAsTyped(calculatedURI); + } + } + } else { + // visited + // visited bookmarks get the visited bookmark bonus twice + if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK) { + bonusValue = bonusValue * 2; + } + + let points = Math.ceil( + (1 * ((bonusValue / parseFloat(100.0)).toFixed(6) * weight)) / 1 + ); + if (!points) { + if ( + visitType == Ci.nsINavHistoryService.TRANSITION_EMBED || + visitType == Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK || + visitType == Ci.nsINavHistoryService.TRANSITION_DOWNLOAD || + visitType == Ci.nsINavHistoryService.TRANSITION_RELOAD || + bonusName == "defaultVisitBonus" + ) { + frecency = 0; + } else { + frecency = -1; + } + } else { + frecency = points; + } + calculatedURI = Services.io.newURI( + "http://" + + searchTerm + + ".com/" + + bonusName + + ":" + + bonusValue + + "/cutoff:" + + cutoff + + "/weight:" + + weight + + "/frecency:" + + frecency + ); + if (visitType == Ci.nsINavHistoryService.TRANSITION_BOOKMARK) { + matchTitle = searchTerm + "Bookmarked"; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: calculatedURI, + title: matchTitle, + }); + } else { + matchTitle = calculatedURI.spec.substr( + calculatedURI.spec.lastIndexOf("/") + 1 + ); + } + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + transition: visitType, + visitDate: dateInPeriod, + }); + } + + if (calculatedURI && frecency) { + results.push([calculatedURI, frecency, matchTitle]); + await PlacesTestUtils.addVisits({ + uri: calculatedURI, + title: matchTitle, + transition: visitType, + visitDate: dateInPeriod, + }); + } + } +} + +add_task(async function test_frecency() { + // Disable autoFill for this test. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + for (let bucket of bucketPrefs) { + await task_initializeBucket(bucket); + } + + // Sort results by frecency. Break ties by alphabetical URL. + results.sort((a, b) => { + let frecencyDiff = b[1] - a[1]; + if (frecencyDiff == 0) { + return a[0].spec.localeCompare(b[0].spec); + } + return frecencyDiff; + }); + + // Make sure there's enough results returned + Services.prefs.setIntPref( + "browser.urlbar.maxRichResults", + // +1 for the heuristic search result. + results.length + 1 + ); + + await PlacesTestUtils.promiseAsyncUpdates(); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let result of results) { + let url = result[0].spec; + if (url.toLowerCase().includes("bookmark")) { + urlbarResults.push( + makeBookmarkResult(context, { + uri: url, + title: result[2], + }) + ); + } else { + urlbarResults.push( + makeVisitResult(context, { + uri: url, + title: result[2], + }) + ); + } + } + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js new file mode 100644 index 0000000000..d94de4f439 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests test the UrlbarController in association with the model. + */ + +"use strict"; + +const { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +const TEST_URL = "http://example.com"; +const match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL } +); +let controller; + +add_task(async function setup() { + controller = UrlbarTestUtils.newMockController(); +}); + +add_task(async function test_basic_search() { + let provider = registerBasicTestProvider([match]); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let startedPromise = promiseControllerNotification( + controller, + "onQueryStarted" + ); + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + controller.startQuery(context); + + let params = await startedPromise; + + Assert.equal(params[0], context); + + params = await resultsPromise; + + Assert.deepEqual( + params[0].results, + [match], + "Should have the expected match" + ); +}); + +add_task(async function test_cancel_search() { + let providerCanceledDeferred = PromiseUtils.defer(); + let provider = registerBasicTestProvider( + [match], + providerCanceledDeferred.resolve + ); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let startedPromise = promiseControllerNotification( + controller, + "onQueryStarted" + ); + let cancelPromise = promiseControllerNotification( + controller, + "onQueryCancelled" + ); + + controller.startQuery(context); + + let params = await startedPromise; + + controller.cancelQuery(context); + + Assert.equal(params[0], context); + + info("Should tell the provider the query is canceled"); + await providerCanceledDeferred.promise; + + params = await cancelPromise; +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js b/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js new file mode 100644 index 0000000000..bce6bb21ff --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_telemetry.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +const TEST_URL = "http://example.com"; +const MATCH = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL } +); +const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS"; +const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS"; + +let controller; +let firstHistogram; +let sixthHistogram; + +/** + * A delayed test provider, allowing the query to be delayed for an amount of time. + */ +class DelayedProvider extends TestProvider { + async startQuery(context, add) { + Assert.ok(context, "context is passed-in"); + Assert.equal(typeof add, "function", "add is a callback"); + this._add = add; + await new Promise(resolve => { + this._resultsAdded = resolve; + }); + } + async addResults(matches, finish = true) { + // startQuery may have not been invoked yet, so wait for it + await TestUtils.waitForCondition( + () => !!this._add, + "Waiting for the _add callback" + ); + for (const match of matches) { + this._add(this, match); + } + if (finish) { + this._add = null; + this._resultsAdded(); + } + } +} + +/** + * Returns the number of reports sent recorded within the histogram results. + * + * @param {object} results a snapshot of histogram results to check. + * @returns {number} The count of reports recorded in the histogram. + */ +function getHistogramReportsCount(results) { + let sum = 0; + for (let [, value] of Object.entries(results.values)) { + sum += value; + } + return sum; +} + +add_task(function setup() { + controller = UrlbarTestUtils.newMockController(); + + firstHistogram = Services.telemetry.getHistogramById(TELEMETRY_1ST_RESULT); + sixthHistogram = Services.telemetry.getHistogramById( + TELEMETRY_6_FIRST_RESULTS + ); +}); + +add_task(async function test_n_autocomplete_cancel() { + firstHistogram.clear(); + sixthHistogram.clear(); + + let providerCanceledDeferred = PromiseUtils.defer(); + let provider = new TestProvider({ + results: [], + onCancel: providerCanceledDeferred.resolve, + }); + UrlbarProvidersManager.registerProvider(provider); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should not have started first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should not have started first 6 results stopwatch" + ); + + controller.startQuery(context); + + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have started first result stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have started first 6 results stopwatch" + ); + + controller.cancelQuery(context); + + await providerCanceledDeferred.promise; + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have canceled first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have canceled first 6 results stopwatch" + ); + + let results = firstHistogram.snapshot(); + Assert.equal( + results.sum, + 0, + "Should not have recorded any times (first result)" + ); + results = sixthHistogram.snapshot(); + Assert.equal( + results.sum, + 0, + "Should not have recorded any times (first 6 results)" + ); +}); + +add_task(async function test_n_autocomplete_results() { + firstHistogram.clear(); + sixthHistogram.clear(); + + let provider = new DelayedProvider(); + UrlbarProvidersManager.registerProvider(provider); + const context = createContext(TEST_URL, { providers: [provider.name] }); + + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should not have started first result stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should not have started first 6 results stopwatch" + ); + + controller.startQuery(context); + + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have started first result stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have started first 6 results stopwatch" + ); + + await provider.addResults([MATCH], false); + await resultsPromise; + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have stopped the first stopwatch" + ); + Assert.ok( + TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have kept the first 6 results stopwatch running" + ); + + let firstResults = firstHistogram.snapshot(); + let first6Results = sixthHistogram.snapshot(); + Assert.equal( + getHistogramReportsCount(firstResults), + 1, + "Should have recorded one time for the first result" + ); + Assert.equal( + getHistogramReportsCount(first6Results), + 0, + "Should not have recorded any times (first 6 results)" + ); + + // Now add 5 more results, so that the first 6 results is triggered. + for (let i = 0; i < 5; i++) { + resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + await provider.addResults( + [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL + "/" + i } + ), + ], + false + ); + await resultsPromise; + } + + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_1ST_RESULT, context), + "Should have stopped the first stopwatch" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_6_FIRST_RESULTS, context), + "Should have stopped the first 6 results stopwatch" + ); + + let updatedResults = firstHistogram.snapshot(); + let updated6Results = sixthHistogram.snapshot(); + Assert.deepEqual( + updatedResults, + firstResults, + "Should not have changed the histogram for the first result" + ); + Assert.equal( + getHistogramReportsCount(updated6Results), + 1, + "Should have recorded one time for the first 6 results" + ); + + // Add one more, to check neither are updated. + resultsPromise = promiseControllerNotification(controller, "onQueryResults"); + await provider.addResults([ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL + "/6" } + ), + ]); + await resultsPromise; + + let secondUpdateResults = firstHistogram.snapshot(); + let secondUpdate6Results = sixthHistogram.snapshot(); + Assert.deepEqual( + secondUpdateResults, + firstResults, + "Should not have changed the histogram for the first result" + ); + Assert.equal( + getHistogramReportsCount(secondUpdate6Results), + 1, + "Should not have changed the histogram for the first 6 results" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js b/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js new file mode 100644 index 0000000000..6d77e6a1ac --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_unit.js @@ -0,0 +1,389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +// A fake ProvidersManager. +let fPM; +let sandbox; +let generalListener; +let controller; + +/** + * Asserts that the query context has the expected values. + * + * @param {UrlbarQueryContext} context The query context. + * @param {object} expectedValues The expected values for the UrlbarQueryContext. + */ +function assertContextMatches(context, expectedValues) { + Assert.ok( + context instanceof UrlbarQueryContext, + "Should be a UrlbarQueryContext" + ); + + for (let [key, value] of Object.entries(expectedValues)) { + Assert.equal( + context[key], + value, + `Should have the expected value for ${key} in the UrlbarQueryContext` + ); + } +} + +add_task(function setup() { + sandbox = sinon.createSandbox(); + + fPM = { + startQuery: sandbox.stub(), + cancelQuery: sandbox.stub(), + }; + + generalListener = { + onQueryStarted: sandbox.stub(), + onQueryResults: sandbox.stub(), + onQueryCancelled: sandbox.stub(), + }; + + controller = UrlbarTestUtils.newMockController({ + manager: fPM, + }); + controller.addQueryListener(generalListener); +}); + +add_task(function test_constructor_throws() { + Assert.throws( + () => new UrlbarController(), + /Missing options: input/, + "Should throw if the input was not supplied" + ); + Assert.throws( + () => new UrlbarController({ input: {} }), + /input is missing 'window' property/, + "Should throw if the input is not a UrlbarInput" + ); + Assert.throws( + () => new UrlbarController({ input: { window: {} } }), + /input.window should be an actual browser window/, + "Should throw if the input.window is not a window" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: "about:fake", + }, + }, + }), + /input.window should be an actual browser window/, + "Should throw if the input.window is not an object" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: { + href: "about:fake", + }, + }, + }, + }), + /input.window should be an actual browser window/, + "Should throw if the input.window does not have the correct location" + ); + Assert.throws( + () => + new UrlbarController({ + input: { + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }), + /input.isPrivate must be set/, + "Should throw if input.isPrivate is not set" + ); + + new UrlbarController({ + input: { + isPrivate: false, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + Assert.ok(true, "Correct call should not throw"); +}); + +add_task(function test_add_and_remove_listeners() { + Assert.throws( + () => controller.addQueryListener(null), + /Expected listener to be an object/, + "Should throw for a null listener" + ); + Assert.throws( + () => controller.addQueryListener(123), + /Expected listener to be an object/, + "Should throw for a non-object listener" + ); + + const listener = {}; + + controller.addQueryListener(listener); + + Assert.ok( + controller._listeners.has(listener), + "Should have added the listener to the list." + ); + + // Adding a non-existent listener shouldn't throw. + controller.removeQueryListener(123); + + controller.removeQueryListener(listener); + + Assert.ok( + !controller._listeners.has(listener), + "Should have removed the listener from the list" + ); + + sandbox.resetHistory(); +}); + +add_task(function test__notify() { + const listener1 = { + onFake: sandbox.stub().callsFake(() => { + throw new Error("fake error"); + }), + }; + const listener2 = { + onFake: sandbox.stub(), + }; + + controller.addQueryListener(listener1); + controller.addQueryListener(listener2); + + const param = "1234"; + + controller.notify("onFake", param); + + Assert.equal( + listener1.onFake.callCount, + 1, + "Should have called the first listener method." + ); + Assert.deepEqual( + listener1.onFake.args[0], + [param], + "Should have called the first listener with the correct argument" + ); + Assert.equal( + listener2.onFake.callCount, + 1, + "Should have called the second listener method." + ); + Assert.deepEqual( + listener2.onFake.args[0], + [param], + "Should have called the first listener with the correct argument" + ); + + controller.removeQueryListener(listener2); + controller.removeQueryListener(listener1); + + // This should succeed without errors. + controller.notify("onNewFake"); + + sandbox.resetHistory(); +}); + +add_task(function test_handle_query_starts_search() { + const context = createContext(); + controller.startQuery(context); + + Assert.equal( + fPM.startQuery.callCount, + 1, + "Should have called startQuery once" + ); + Assert.equal( + fPM.startQuery.args[0].length, + 2, + "Should have called startQuery with two arguments" + ); + + assertContextMatches(fPM.startQuery.args[0][0], {}); + Assert.equal( + fPM.startQuery.args[0][1], + controller, + "Should have passed the controller as the second argument" + ); + + Assert.equal( + generalListener.onQueryStarted.callCount, + 1, + "Should have called onQueryStarted for the listener" + ); + Assert.deepEqual( + generalListener.onQueryStarted.args[0], + [context], + "Should have called onQueryStarted with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(async function test_handle_query_starts_search_sets_allowAutofill() { + let originalValue = Services.prefs.getBoolPref("browser.urlbar.autoFill"); + Services.prefs.setBoolPref("browser.urlbar.autoFill", !originalValue); + + await controller.startQuery(createContext()); + + Assert.equal( + fPM.startQuery.callCount, + 1, + "Should have called startQuery once" + ); + Assert.equal( + fPM.startQuery.args[0].length, + 2, + "Should have called startQuery with two arguments" + ); + + assertContextMatches(fPM.startQuery.args[0][0], { + allowAutofill: !originalValue, + }); + Assert.equal( + fPM.startQuery.args[0][1], + controller, + "Should have passed the controller as the second argument" + ); + + sandbox.resetHistory(); + + Services.prefs.clearUserPref("browser.urlbar.autoFill"); +}); + +add_task(function test_cancel_query() { + const context = createContext(); + controller.startQuery(context); + + controller.cancelQuery(); + + Assert.equal( + fPM.cancelQuery.callCount, + 1, + "Should have called cancelQuery once" + ); + Assert.equal( + fPM.cancelQuery.args[0].length, + 1, + "Should have called cancelQuery with one argument" + ); + + Assert.equal( + generalListener.onQueryCancelled.callCount, + 1, + "Should have called onQueryCancelled for the listener" + ); + Assert.deepEqual( + generalListener.onQueryCancelled.args[0], + [context], + "Should have called onQueryCancelled with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(function test_receiveResults() { + const context = createContext(); + context.results = []; + controller.receiveResults(context); + + Assert.equal( + generalListener.onQueryResults.callCount, + 1, + "Should have called onQueryResults for the listener" + ); + Assert.deepEqual( + generalListener.onQueryResults.args[0], + [context], + "Should have called onQueryResults with the context" + ); + + sandbox.resetHistory(); +}); + +add_task(async function test_notifications_order() { + // Clear any pending notifications. + const context = createContext(); + await controller.startQuery(context); + + // Check that when multiple queries are executed, the notifications arrive + // in the proper order. + let collectingListener = new Proxy( + {}, + { + _notifications: [], + get(target, name) { + if (name == "notifications") { + return this._notifications; + } + return () => { + this._notifications.push(name); + }; + }, + } + ); + controller.addQueryListener(collectingListener); + controller.startQuery(context); + Assert.deepEqual( + ["onQueryStarted"], + collectingListener.notifications, + "Check onQueryStarted is fired synchronously" + ); + controller.startQuery(context); + Assert.deepEqual( + ["onQueryStarted", "onQueryCancelled", "onQueryFinished", "onQueryStarted"], + collectingListener.notifications, + "Check order of notifications" + ); + controller.cancelQuery(); + Assert.deepEqual( + [ + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + ], + collectingListener.notifications, + "Check order of notifications" + ); + await controller.startQuery(context); + controller.cancelQuery(); + Assert.deepEqual( + [ + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryCancelled", + "onQueryFinished", + "onQueryStarted", + "onQueryFinished", + ], + collectingListener.notifications, + "Check order of notifications" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js b/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js new file mode 100644 index 0000000000..c681aca387 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js @@ -0,0 +1,449 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(function test() { + Assert.throws( + () => UrlbarPrefs.get("browser.migration.version"), + /Trying to access an unknown pref/, + "Should throw when passing an untracked pref" + ); + + Assert.throws( + () => UrlbarPrefs.set("browser.migration.version", 100), + /Trying to access an unknown pref/, + "Should throw when passing an untracked pref" + ); + Assert.throws( + () => UrlbarPrefs.set("maxRichResults", "10"), + /Invalid value/, + "Should throw when passing an invalid value type" + ); + + Assert.deepEqual(UrlbarPrefs.get("formatting.enabled"), true); + UrlbarPrefs.set("formatting.enabled", false); + Assert.deepEqual(UrlbarPrefs.get("formatting.enabled"), false); + + Assert.deepEqual(UrlbarPrefs.get("maxRichResults"), 10); + UrlbarPrefs.set("maxRichResults", 6); + Assert.deepEqual(UrlbarPrefs.get("maxRichResults"), 6); + + Assert.deepEqual(UrlbarPrefs.get("autoFill.stddevMultiplier"), 0.0); + UrlbarPrefs.set("autoFill.stddevMultiplier", 0.01); + // Due to rounding errors, floats are slightly imprecise, so we can't + // directly compare what we set to what we retrieve. + Assert.deepEqual( + parseFloat(UrlbarPrefs.get("autoFill.stddevMultiplier").toFixed(2)), + 0.01 + ); +}); + +// Tests UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }). +add_task(function makeResultGroups_true() { + Assert.deepEqual( + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_PRELOADED }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + // main group + { + flexChildren: true, + children: [ + // suggestions + { + flex: 2, + children: [ + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 4, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION, + }, + ], + }, + // general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flex: 1, + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_TAB, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.ABOUT_PAGES, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.PRELOADED, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + ], + }, + ], + }, + ], + } + ); +}); + +// Tests UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }). +add_task(function makeResultGroups_false() { + Assert.deepEqual( + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + + { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_ENGINE_ALIAS }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_BOOKMARK_KEYWORD }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_PRELOADED }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_HISTORY_URL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + // main group + { + flexChildren: true, + children: [ + // general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flex: 2, + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_TAB, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.ABOUT_PAGES, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.PRELOADED, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.INPUT_HISTORY, + }, + ], + }, + // suggestions + { + flex: 1, + children: [ + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 4, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + group: UrlbarUtils.RESULT_GROUP.TAIL_SUGGESTION, + }, + ], + }, + ], + }, + ], + } + ); +}); + +// Tests interaction between showSearchSuggestionsFirst and resultGroups. +add_task(function showSearchSuggestionsFirst_resultGroups() { + // Check initial values. + Assert.equal( + UrlbarPrefs.get("showSearchSuggestionsFirst"), + true, + "showSearchSuggestionsFirst is true initially" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is the same as the groups for which howSearchSuggestionsFirst is true" + ); + + // Set showSearchSuggestionsFirst = false. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + "resultGroups is updated after setting showSearchSuggestionsFirst = false" + ); + + // Set showSearchSuggestionsFirst = true. + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is updated after setting showSearchSuggestionsFirst = true" + ); + + // Set showSearchSuggestionsFirst = false again so we can clear it next. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: false }), + "resultGroups is updated after setting showSearchSuggestionsFirst = false" + ); + + // Clear showSearchSuggestionsFirst. + Services.prefs.clearUserPref("browser.urlbar.showSearchSuggestionsFirst"); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups is updated immediately after clearing showSearchSuggestionsFirst" + ); + Assert.equal( + UrlbarPrefs.get("showSearchSuggestionsFirst"), + true, + "showSearchSuggestionsFirst defaults to true after clearing it" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst: true }), + "resultGroups remains correct after getting showSearchSuggestionsFirst" + ); +}); + +// Tests UrlbarPrefs.initializeShowSearchSuggestionsFirstPref() and the +// interaction between matchGroups, showSearchSuggestionsFirst, and +// resultGroups. It's a little complex, but the flow is: +// +// 1. The old matchGroups pref has some value +// 2. UrlbarPrefs.initializeShowSearchSuggestionsFirstPref() is called to +// translate matchGroups into the newer showSearchSuggestionsFirst pref +// 3. The update to showSearchSuggestionsFirst causes the new resultGroups +// pref to be set +add_task(function initializeShowSearchSuggestionsFirstPref() { + // Each value in `tests`: [matchGroups, expectedShowSearchSuggestionsFirst] + let tests = [ + ["suggestion:4,general:Infinity", true], + ["suggestion:4,general:5", true], + ["suggestion:1,general:5,suggestion:Infinity", true], + ["suggestion:Infinity", true], + ["suggestion:4", true], + + ["foo:1,suggestion:4,general:Infinity", true], + ["foo:2,suggestion:4,general:5", true], + ["foo:3,suggestion:1,general:5,suggestion:Infinity", true], + ["foo:4,suggestion:Infinity", true], + ["foo:5,suggestion:4", true], + + ["general:5,suggestion:Infinity", false], + ["general:5,suggestion:4", false], + ["general:1,suggestion:4,general:Infinity", false], + ["general:Infinity", false], + ["general:5", false], + + ["foo:1,general:5,suggestion:Infinity", false], + ["foo:2,general:5,suggestion:4", false], + ["foo:3,general:1,suggestion:4,general:Infinity", false], + ["foo:4,general:Infinity", false], + ["foo:5,general:5", false], + + ["", true], + ["bogus groups", true], + ]; + + for (let [matchGroups, expectedValue] of tests) { + info("Running test: " + JSON.stringify({ matchGroups, expectedValue })); + Services.prefs.clearUserPref("browser.urlbar.showSearchSuggestionsFirst"); + + // Set matchGroups. + Services.prefs.setCharPref("browser.urlbar.matchGroups", matchGroups); + + // Call initializeShowSearchSuggestionsFirstPref. + UrlbarPrefs.initializeShowSearchSuggestionsFirstPref(); + + // Both showSearchSuggestionsFirst and resultGroups should be updated. + Assert.equal( + Services.prefs.getBoolPref("browser.urlbar.showSearchSuggestionsFirst"), + expectedValue, + "showSearchSuggestionsFirst has the expected value" + ); + Assert.deepEqual( + UrlbarPrefs.resultGroups, + UrlbarPrefs.makeResultGroups({ + showSearchSuggestionsFirst: expectedValue, + }), + "resultGroups should be updated with the appropriate default" + ); + } + + Services.prefs.clearUserPref("browser.urlbar.matchGroups"); +}); + +// Tests whether observer.onNimbusChanged works. +add_task(async function onNimbusChanged() { + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + false + ); + + // Add an observer that throws an Error and an observer that does not define + // anything to check whether the other observers can get notifications. + UrlbarPrefs.addObserver({ + onPrefChanged(pref) { + throw new Error("From onPrefChanged"); + }, + onNimbusChanged(pref) { + throw new Error("From onNimbusChanged"); + }, + }); + UrlbarPrefs.addObserver({}); + + const observer = { + onPrefChanged(pref) { + this.prefChangedList.push(pref); + }, + onNimbusChanged(pref) { + this.nimbusChangedList.push(pref); + }, + }; + observer.prefChangedList = []; + observer.nimbusChangedList = []; + UrlbarPrefs.addObserver(observer); + + const doCleanup = await UrlbarTestUtils.initNimbusFeature({ + autoFillAdaptiveHistoryEnabled: true, + }); + Assert.equal(observer.prefChangedList.length, 0); + Assert.ok( + observer.nimbusChangedList.includes("autoFillAdaptiveHistoryEnabled") + ); + doCleanup(); +}); + +// Tests whether observer.onPrefChanged works. +add_task(async function onPrefChanged() { + const doCleanup = await UrlbarTestUtils.initNimbusFeature({ + autoFillAdaptiveHistoryEnabled: false, + }); + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + false + ); + + // Add an observer that throws an Error and an observer that does not define + // anything to check whether the other observers can get notifications. + UrlbarPrefs.addObserver({ + onPrefChanged(pref) { + throw new Error("From onPrefChanged"); + }, + onNimbusChanged(pref) { + throw new Error("From onNimbusChanged"); + }, + }); + UrlbarPrefs.addObserver({}); + + const deferred = PromiseUtils.defer(); + const observer = { + onPrefChanged(pref) { + this.prefChangedList.push(pref); + deferred.resolve(); + }, + onNimbusChanged(pref) { + this.nimbusChangedList.push(pref); + deferred.resolve(); + }, + }; + observer.prefChangedList = []; + observer.nimbusChangedList = []; + UrlbarPrefs.addObserver(observer); + + Services.prefs.setBoolPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled", + true + ); + await deferred.promise; + Assert.equal(observer.prefChangedList.length, 1); + Assert.equal(observer.prefChangedList[0], "autoFill.adaptiveHistory.enabled"); + Assert.equal(observer.nimbusChangedList.length, 0); + + Services.prefs.clearUserPref( + "browser.urlbar.autoFill.adaptiveHistory.enabled" + ); + doCleanup(); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js new file mode 100644 index 0000000000..e30e2fa0eb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(function test_constructor() { + Assert.throws( + () => new UrlbarQueryContext(), + /Missing or empty allowAutofill provided to UrlbarQueryContext/, + "Should throw with no arguments" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + allowAutofill: true, + isPrivate: false, + searchString: "foo", + }), + /Missing or empty maxResults provided to UrlbarQueryContext/, + "Should throw with a missing maxResults parameter" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + allowAutofill: true, + maxResults: 1, + searchString: "foo", + }), + /Missing or empty isPrivate provided to UrlbarQueryContext/, + "Should throw with a missing isPrivate parameter" + ); + + Assert.throws( + () => + new UrlbarQueryContext({ + isPrivate: false, + maxResults: 1, + searchString: "foo", + }), + /Missing or empty allowAutofill provided to UrlbarQueryContext/, + "Should throw with a missing allowAutofill parameter" + ); + + let qc = new UrlbarQueryContext({ + allowAutofill: false, + isPrivate: true, + maxResults: 1, + searchString: "foo", + }); + + Assert.strictEqual( + qc.allowAutofill, + false, + "Should have saved the correct value for allowAutofill" + ); + Assert.strictEqual( + qc.isPrivate, + true, + "Should have saved the correct value for isPrivate" + ); + Assert.equal( + qc.maxResults, + 1, + "Should have saved the correct value for maxResults" + ); + Assert.equal( + qc.searchString, + "foo", + "Should have saved the correct value for searchString" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js new file mode 100644 index 0000000000..0aadb8aef6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.js @@ -0,0 +1,132 @@ +/* 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 restrictions set through UrlbarQueryContext.sources. + */ + +testEngine_setup(); + +add_task(async function test_restrictions() { + await PlacesTestUtils.addVisits([ + { uri: "http://history.com/", title: "match" }, + ]); + await PlacesUtils.bookmarks.insert({ + url: "http://bookmark.com/", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "match", + }); + await UrlbarProviderOpenTabs.registerOpenTab( + "http://openpagematch.com/", + 0, + false + ); + + info("Bookmark restrict"); + let results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.BOOKMARKS], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://bookmark.com/"] + ); + + info("History restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.HISTORY], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://history.com/"] + ); + + info("tabs restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.TABS], + searchString: "match", + }); + // Skip the heuristic result. + Assert.deepEqual( + results.filter(r => !r.heuristic).map(r => r.payload.url), + ["http://openpagematch.com/"] + ); + + info("search restrict"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match", + }); + Assert.ok( + !results.some(r => r.payload.engine != SUGGESTIONS_ENGINE_NAME), + "All the results should be search results" + ); + + info("search restrict should ignore restriction token"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARKS} match`, + }); + Assert.ok( + !results.some(r => r.payload.engine != SUGGESTIONS_ENGINE_NAME), + "All the results should be search results" + ); + Assert.equal( + results[0].payload.query, + `${UrlbarTokenizer.RESTRICT.BOOKMARKS} match`, + "The restriction token should be ignored and not stripped" + ); + + info("search restrict with alias"); + await SearchTestUtils.installSearchExtension({ + name: "Test", + keyword: "match", + }); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match this", + }); + Assert.ok( + !results.some(r => r.payload.engine != SUGGESTIONS_ENGINE_NAME), + "All the results should be search results and the alias should be ignored" + ); + Assert.equal( + results[0].payload.query, + `match this`, + "The restriction token should be ignored and not stripped" + ); + + info("search restrict with other engine"); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match", + engineName: "Test", + }); + Assert.ok( + !results.some(r => r.payload.engine != "Test"), + "All the results should be search results from the Test engine" + ); +}); + +async function get_results(test) { + let controller = UrlbarTestUtils.newMockController(); + let options = { + allowAutofill: false, + isPrivate: false, + maxResults: 10, + sources: test.sources, + }; + if (test.engineName) { + options.searchMode = { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: test.engineName, + }; + } + let queryContext = createContext(test.searchString, options); + await controller.startQuery(queryContext); + return queryContext.results; +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js new file mode 100644 index 0000000000..a39815937e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.js @@ -0,0 +1,462 @@ +/* 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/. */ + +const { UrlbarSearchUtils } = ChromeUtils.importESModule( + "resource:///modules/UrlbarSearchUtils.sys.mjs" +); + +let baconEngineExtension; + +add_task(async function () { + await UrlbarSearchUtils.init(); + // Tell the search service we are running in the US. This also has the + // desired side-effect of preventing our geoip lookup. + Services.prefs.setCharPref("browser.search.region", "US"); + + Services.search.restoreDefaultEngines(); + Services.search.resetToAppDefaultEngine(); +}); + +add_task(async function search_engine_match() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + let matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix(token) + )[0]; + Assert.equal(matchedEngine, engine); +}); + +add_task(async function no_match() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("test")).length + ); +}); + +add_task(async function hide_search_engine_nomatch() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + let promiseTopic = promiseSearchTopic("engine-changed"); + await Promise.all([Services.search.removeEngine(engine), promiseTopic]); + Assert.ok(engine.hidden); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token); + Assert.ok( + !matchedEngines.length || matchedEngines[0].searchUrlDomain != domain + ); + engine.hidden = false; + await TestUtils.waitForCondition( + async () => (await UrlbarSearchUtils.enginesForDomainPrefix(token)).length + ); + let matchedEngine2 = ( + await UrlbarSearchUtils.enginesForDomainPrefix(token) + )[0]; + Assert.ok(matchedEngine2); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); + +add_task(async function onlyEnabled_option_nomatch() { + let engine = await Services.search.getDefault(); + let domain = engine.searchUrlDomain; + let token = domain.substr(0, 1); + Services.prefs.setCharPref("browser.search.hiddenOneOffs", engine.name); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.notEqual(matchedEngines[0].searchUrlDomain, domain); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.equal(matchedEngines[0].searchUrlDomain, domain); +}); + +add_task(async function add_search_engine_match() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("bacon")).length + ); + baconEngineExtension = await SearchTestUtils.installSearchExtension( + { + name: "bacon", + keyword: "pork", + search_url: "https://www.bacon.moz/", + }, + { skipUnload: true } + ); + let matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix("bacon") + )[0]; + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngine.name, "bacon"); + Assert.equal(matchedEngine.iconURI, null); + info("also type part of the public suffix"); + matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix("bacon.m") + )[0]; + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngine.name, "bacon"); + Assert.equal(matchedEngine.iconURI, null); +}); + +add_task(async function match_multiple_search_engines() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("baseball")).length + ); + await SearchTestUtils.installSearchExtension({ + name: "baseball", + search_url: "https://www.baseball.moz/", + }); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix("ba"); + Assert.equal( + matchedEngines.length, + 2, + "enginesForDomainPrefix returned two engines." + ); + Assert.equal(matchedEngines[0].searchForm, "https://www.bacon.moz"); + Assert.equal(matchedEngines[0].name, "bacon"); + Assert.equal(matchedEngines[1].searchForm, "https://www.baseball.moz"); + Assert.equal(matchedEngines[1].name, "baseball"); +}); + +add_task(async function test_aliased_search_engine_match() { + Assert.equal(null, await UrlbarSearchUtils.engineForAlias("sober")); + // Lower case + let matchedEngine = await UrlbarSearchUtils.engineForAlias("pork"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.iconURI, null); + // Upper case + matchedEngine = await UrlbarSearchUtils.engineForAlias("PORK"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.iconURI, null); + // Cap case + matchedEngine = await UrlbarSearchUtils.engineForAlias("Pork"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "bacon"); + Assert.ok(matchedEngine.aliases.includes("pork")); + Assert.equal(matchedEngine.iconURI, null); +}); + +add_task(async function test_aliased_search_engine_match_upper_case_alias() { + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("patch")).length + ); + await SearchTestUtils.installSearchExtension({ + name: "patch", + keyword: "PR", + search_url: "https://www.patch.moz/", + }); + // lower case + let matchedEngine = await UrlbarSearchUtils.engineForAlias("pr"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.iconURI, null); + // Upper case + matchedEngine = await UrlbarSearchUtils.engineForAlias("PR"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.iconURI, null); + // Cap case + matchedEngine = await UrlbarSearchUtils.engineForAlias("Pr"); + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.name, "patch"); + Assert.ok(matchedEngine.aliases.includes("PR")); + Assert.equal(matchedEngine.iconURI, null); +}); + +add_task(async function remove_search_engine_nomatch() { + let promiseTopic = promiseSearchTopic("engine-removed"); + await Promise.all([baconEngineExtension.unload(), promiseTopic]); + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("bacon")).length + ); +}); + +add_task(async function test_builtin_aliased_search_engine_match() { + let engine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(engine); + Assert.equal(engine.name, "Google"); + let promiseTopic = promiseSearchTopic("engine-changed"); + await Promise.all([Services.search.removeEngine(engine), promiseTopic]); + let matchedEngine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(!matchedEngine); + engine.hidden = false; + await TestUtils.waitForCondition(() => + UrlbarSearchUtils.engineForAlias("@google") + ); + engine = await UrlbarSearchUtils.engineForAlias("@google"); + Assert.ok(engine); +}); + +add_task(async function test_serps_are_equivalent() { + info("Subset URL has extraneous parameters."); + let url1 = "https://example.com/search?q=test&type=images"; + let url2 = "https://example.com/search?q=test"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + info("Superset URL has extraneous parameters."); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Same keys, different values."); + url1 = "https://example.com/search?q=test&type=images"; + url2 = "https://example.com/search?q=test123&type=maps"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Subset matching isn't strict (URL is subset of itself)."); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url1)); + + info("Origin and pathname are ignored."); + url1 = "https://example.com/search?q=test"; + url2 = "https://example-1.com/maps?q=test"; + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url2, url1)); + + info("Params can be optionally ignored"); + url1 = "https://example.com/search?q=test&abc=123&foo=bar"; + url2 = "https://example.com/search?q=test"; + Assert.ok(!UrlbarSearchUtils.serpsAreEquivalent(url1, url2)); + Assert.ok(UrlbarSearchUtils.serpsAreEquivalent(url1, url2, ["abc", "foo"])); +}); + +add_task(async function test_get_root_domain_from_engine() { + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine2", + search_url: "https://example.com/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("TestEngine2"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await extension.unload(); + + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: "https://www.subdomain.othersubdomain.example.com", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestEngine"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await extension.unload(); + + // We let engines with URL ending in .test through even though its not a valid + // TLD. + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMalformed", + search_url: "https://mochi.test/", + search_url_get_params: "search={searchTerms}", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestMalformed"); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "mochi"); + await extension.unload(); + + // We return the domain for engines with a malformed URL. + extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMalformed", + search_url: "https://subdomain.foobar/", + search_url_get_params: "search={searchTerms}", + }, + { skipUnload: true } + ); + engine = Services.search.getEngineByName("TestMalformed"); + Assert.equal( + UrlbarSearchUtils.getRootDomainFromEngine(engine), + "subdomain.foobar" + ); + await extension.unload(); +}); + +// Tests getSearchTermIfDefaultSerpUri() by using a variety of +// input strings and nsIURI's. +// Should not throw an error if the consumer passes an input +// that when accessed, could cause an error. +add_task(async function get_search_term_if_default_serp_uri() { + let testCases = [ + { + url: null, + skipUriTest: true, + }, + { + url: "", + skipUriTest: true, + }, + { + url: "about:blank", + }, + { + url: "about:home", + }, + { + url: "about:newtab", + }, + { + url: "not://a/supported/protocol", + }, + { + url: "view-source:http://www.example.com/", + }, + { + // Not a default engine. + url: "http://mochi.test:8888/?q=chocolate&pc=sample_code", + }, + { + // Not the correct protocol. + url: "http://example.com/?q=chocolate&pc=sample_code", + }, + { + // Not the same query param values. + url: "https://example.com/?q=chocolate&pc=sample_code2", + }, + { + // Not the same query param values. + url: "https://example.com/?q=chocolate&pc=sample_code&pc2=sample_code_2", + }, + { + url: "https://example.com/?q=chocolate&pc=sample_code", + expectedString: "chocolate", + }, + { + url: "https://example.com/?q=chocolate+cakes&pc=sample_code", + expectedString: "chocolate cakes", + }, + ]; + + // Create a specific engine so that the tests are matched + // exactly against the query params used. + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: "https://example.com/", + search_url_get_params: "?q={searchTerms}&pc=sample_code", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("TestEngine"); + let originalDefaultEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + for (let testCase of testCases) { + let expectedString = testCase.expectedString ?? ""; + Assert.equal( + UrlbarSearchUtils.getSearchTermIfDefaultSerpUri(testCase.url), + expectedString, + `Should return ${ + expectedString == "" ? "an empty string" : "a matching search string" + }` + ); + // Convert the string into a nsIURI and then + // try the test case with it. + if (!testCase.skipUriTest) { + Assert.equal( + UrlbarSearchUtils.getSearchTermIfDefaultSerpUri( + Services.io.newURI(testCase.url) + ), + expectedString, + `Should return ${ + expectedString == "" ? "an empty string" : "a matching search string" + }` + ); + } + } + + Services.search.defaultEngine = originalDefaultEngine; + await extension.unload(); +}); + +add_task(async function matchAllDomainLevels() { + let baseHostname = "matchalldomainlevels"; + Assert.equal( + (await UrlbarSearchUtils.enginesForDomainPrefix(baseHostname)).length, + 0, + `Sanity check: No engines initially match ${baseHostname}` + ); + + // Install engines with the following domains. When we match engines below, + // perfectly matching domains should come before partially matching domains. + let baseDomain = `${baseHostname}.com`; + let perfectDomains = [baseDomain, `www.${baseDomain}`]; + let partialDomains = [`foo.${baseDomain}`, `foo.bar.${baseDomain}`]; + + // Install engines with partially matching domains first so that the test + // isn't incidentally passing because engines are installed in the order it + // ultimately expects them in. Wait for each engine to finish installing + // before starting the next one to avoid intermittent out-of-order failures. + let extensions = []; + for (let list of [partialDomains, perfectDomains]) { + for (let domain of list) { + let ext = await SearchTestUtils.installSearchExtension( + { + name: domain, + search_url: `https://${domain}/`, + }, + { skipUnload: true } + ); + extensions.push(ext); + } + } + + // Perfect matches come before partial matches. + let expectedDomains = [...perfectDomains, ...partialDomains]; + + // Do searches for the following strings. Each should match all the engines + // installed above. + let searchStrings = [baseHostname, baseHostname + "."]; + for (let searchString of searchStrings) { + info(`Searching for "${searchString}"`); + let engines = await UrlbarSearchUtils.enginesForDomainPrefix(searchString, { + matchAllDomainLevels: true, + }); + let engineData = engines.map(e => ({ + name: e.name, + searchForm: e.searchForm, + })); + info("Matching engines: " + JSON.stringify(engineData)); + + Assert.equal( + engines.length, + expectedDomains.length, + "Expected number of matching engines" + ); + Assert.deepEqual( + engineData.map(d => d.name), + expectedDomains, + "Expected matching engine names/domains in the expected order" + ); + } + + await Promise.all(extensions.map(e => e.unload())); +}); + +function promiseSearchTopic(expectedVerb) { + return new Promise(resolve => { + Services.obs.addObserver(function observe(subject, topic, verb) { + info("browser-search-engine-modified: " + verb); + if (verb == expectedVerb) { + Services.obs.removeObserver(observe, "browser-search-engine-modified"); + resolve(); + } + }, "browser-search-engine-modified"); + }); +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js new file mode 100644 index 0000000000..6eabc3bb52 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_addToUrlbarHistory.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of the functions in UrlbarUtils. + * Some functions are bigger, and split out into sepearate test_UrlbarUtils_* files. + */ + +"use strict"; + +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); +const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" +); + +let sandbox; + +add_task(function setup() { + sandbox = sinon.createSandbox(); +}); + +add_task(function test_addToUrlbarHistory() { + sandbox.stub(PlacesUIUtils, "markPageAsTyped"); + sandbox.stub(PrivateBrowsingUtils, "isWindowPrivate").returns(false); + + UrlbarUtils.addToUrlbarHistory("http://example.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.calledOnce, + "Should have marked a simple URL as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory(); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have attempted to mark a null URL as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory("http://exam ple.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL containing a space as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + UrlbarUtils.addToUrlbarHistory("http://exam\x01ple.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL containing a control character as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); + + PrivateBrowsingUtils.isWindowPrivate.returns(true); + UrlbarUtils.addToUrlbarHistory("http://example.com"); + Assert.ok( + PlacesUIUtils.markPageAsTyped.notCalled, + "Should not have marked a URL provided by a private browsing page as typed." + ); + PlacesUIUtils.markPageAsTyped.resetHistory(); +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js new file mode 100644 index 0000000000..034005b0fa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests unit test the functionality of UrlbarController by stubbing out the + * model and providing stubs to be called. + */ + +"use strict"; + +function getPostDataString(aIS) { + if (!aIS) { + return null; + } + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(aIS); + let dataLines = sis.read(aIS.available()).split("\n"); + + // only want the last line + return dataLines[dataLines.length - 1]; +} + +function keywordResult(aURL, aPostData, aIsUnsafe) { + this.url = aURL; + this.postData = aPostData; + this.isUnsafe = aIsUnsafe; +} + +function keyWordData() {} +keyWordData.prototype = { + init(aKeyWord, aURL, aPostData, aSearchWord) { + this.keyword = aKeyWord; + this.uri = Services.io.newURI(aURL); + this.postData = aPostData; + this.searchWord = aSearchWord; + + this.method = this.postData ? "POST" : "GET"; + }, +}; + +function bmKeywordData(aKeyWord, aURL, aPostData, aSearchWord) { + this.init(aKeyWord, aURL, aPostData, aSearchWord); +} +bmKeywordData.prototype = new keyWordData(); + +function searchKeywordData(aKeyWord, aURL, aPostData, aSearchWord) { + this.init(aKeyWord, aURL, aPostData, aSearchWord); +} +searchKeywordData.prototype = new keyWordData(); + +var testData = [ + [ + new bmKeywordData("bmget", "https://bmget/search=%s", null, "foo"), + new keywordResult("https://bmget/search=foo", null), + ], + + [ + new bmKeywordData("bmpost", "https://bmpost/", "search=%s", "foo2"), + new keywordResult("https://bmpost/", "search=foo2"), + ], + + [ + new bmKeywordData( + "bmpostget", + "https://bmpostget/search1=%s", + "search2=%s", + "foo3" + ), + new keywordResult("https://bmpostget/search1=foo3", "search2=foo3"), + ], + + [ + new bmKeywordData("bmget-nosearch", "https://bmget-nosearch/", null, ""), + new keywordResult("https://bmget-nosearch/", null), + ], + + [ + new searchKeywordData( + "searchget", + "https://searchget/?search={searchTerms}", + null, + "foo4" + ), + new keywordResult("https://searchget/?search=foo4", null, true), + ], + + [ + new searchKeywordData( + "searchpost", + "https://searchpost/", + "search={searchTerms}", + "foo5" + ), + new keywordResult("https://searchpost/", "search=foo5", true), + ], + + [ + new searchKeywordData( + "searchpostget", + "https://searchpostget/?search1={searchTerms}", + "search2={searchTerms}", + "foo6" + ), + new keywordResult( + "https://searchpostget/?search1=foo6", + "search2=foo6", + true + ), + ], + + // Bookmark keywords that don't take parameters should not be activated if a + // parameter is passed (bug 420328). + [ + new bmKeywordData("bmget-noparam", "https://bmget-noparam/", null, "foo7"), + new keywordResult(null, null, true), + ], + [ + new bmKeywordData( + "bmpost-noparam", + "https://bmpost-noparam/", + "not_a=param", + "foo8" + ), + new keywordResult(null, null, true), + ], + + // Test escaping (%s = escaped, %S = raw) + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "https://bmget/?esc=%s&raw=%S", + null, + "fo\xE9" + ), + new keywordResult("https://bmget/?esc=fo%C3%A9&raw=fo\xE9", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "https://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "fo\xE9" + ), + new keywordResult("https://bmget/?esc=fo%E9&raw=fo\xE9", null), + ], + + // Bug 359809: Test escaping +, /, and @ + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "https://bmget/?esc=%s&raw=%S", + null, + "+/@" + ), + new keywordResult("https://bmget/?esc=%2B%2F%40&raw=+/@", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "https://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "+/@" + ), + new keywordResult("https://bmget/?esc=%2B%2F%40&raw=+/@", null), + ], + + // Test using a non-bmKeywordData object, to test the behavior of + // getShortcutOrURIAndPostData for non-keywords (setupKeywords only adds keywords for + // bmKeywordData objects) + [{ keyword: "https://gavinsharp.com" }, new keywordResult(null, null, true)], +]; + +add_task(async function test_getshortcutoruri() { + await setupKeywords(); + + for (let item of testData) { + let [data, result] = item; + + let query = data.keyword; + if (data.searchWord) { + query += " " + data.searchWord; + } + let returnedData = await UrlbarUtils.getShortcutOrURIAndPostData(query); + // null result.url means we should expect the same query we sent in + let expected = result.url || query; + Assert.equal( + returnedData.url, + expected, + "got correct URL for " + data.keyword + ); + Assert.equal( + getPostDataString(returnedData.postData), + result.postData, + "got correct postData for " + data.keyword + ); + Assert.equal( + returnedData.mayInheritPrincipal, + !result.isUnsafe, + "got correct mayInheritPrincipal for " + data.keyword + ); + } + + await cleanupKeywords(); +}); + +var folder = null; + +async function setupKeywords() { + folder = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: "keyword-test", + }); + for (let item of testData) { + let data = item[0]; + if (data instanceof bmKeywordData) { + await PlacesUtils.bookmarks.insert({ + url: data.uri, + parentGuid: folder.guid, + }); + await PlacesUtils.keywords.insert({ + keyword: data.keyword, + url: data.uri.spec, + postData: data.postData, + }); + } + + if (data instanceof searchKeywordData) { + await SearchTestUtils.installSearchExtension({ + name: data.keyword, + keyword: data.keyword, + search_url: data.uri.spec, + search_url_get_params: "", + search_url_post_params: data.postData, + }); + } + } +} + +async function cleanupKeywords() { + await PlacesUtils.bookmarks.remove(folder); +} diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js new file mode 100644 index 0000000000..bae6ffc879 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getTokenMatches.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests UrlbarUtils.getTokenMatches. + */ + +"use strict"; + +add_task(function test() { + const tests = [ + { + tokens: ["mozilla", "is", "i"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mozilla", "is", "i"], + phrase: "MOZILLA IS for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mozilla", "is", "i"], + phrase: "MoZiLlA Is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["MOZILLA", "IS", "I"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["MoZiLlA", "Is", "I"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 7], + [8, 2], + ], + }, + { + tokens: ["mo", "b"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["mo", "b"], + phrase: "MOZILLA is for the OPEN WEB", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["MO", "B"], + phrase: "mozilla is for the Open Web", + expected: [ + [0, 2], + [26, 1], + ], + }, + { + tokens: ["mo", ""], + phrase: "mozilla is for the Open Web", + expected: [[0, 2]], + }, + { + tokens: ["mozilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "MOZILLA", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "MoZiLlA", + expected: [[0, 7]], + }, + { + tokens: ["mozilla"], + phrase: "mOzIlLa", + expected: [[0, 7]], + }, + { + tokens: ["MOZILLA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["MoZiLlA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mOzIlLa"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["\u9996"], + phrase: "Test \u9996\u9875 Test", + expected: [[5, 1]], + }, + { + tokens: ["mo", "zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "MOZILLA", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "MoZiLlA", + expected: [[0, 7]], + }, + { + tokens: ["mo", "zilla"], + phrase: "mOzIlLa", + expected: [[0, 7]], + }, + { + tokens: ["MO", "ZILLA"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["Mo", "Zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["moz", "zilla"], + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: [""], // Should never happen in practice. + phrase: "mozilla", + expected: [], + }, + { + tokens: ["mo", "om"], + phrase: "mozilla mozzarella momo", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["mo", "om"], + phrase: "MOZILLA MOZZARELLA MOMO", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["MO", "OM"], + phrase: "mozilla mozzarella momo", + expected: [ + [0, 2], + [8, 2], + [19, 4], + ], + }, + { + tokens: ["resume"], + phrase: "résumé", + expected: [[0, 6]], + }, + { + // This test should succeed even in a Spanish locale where N and Ñ are + // considered distinct letters. + tokens: ["jalapeno"], + phrase: "jalapeño", + expected: [[0, 8]], + }, + ]; + for (let { tokens, phrase, expected } of tests) { + tokens = tokens.map(t => ({ + value: t, + lowerCaseValue: t.toLocaleLowerCase(), + })); + Assert.deepEqual( + UrlbarUtils.getTokenMatches(tokens, phrase, UrlbarUtils.HIGHLIGHT.TYPED), + expected, + `Match "${tokens.map(t => t.value).join(", ")}" on "${phrase}"` + ); + } +}); + +/** + * Tests suggestion highlighting. Note that suggestions are only highlighted if + * the matching token is at the beginning of a word in the matched string. + */ +add_task(function testSuggestions() { + const tests = [ + { + tokens: ["mozilla", "is", "i"], + phrase: "mozilla is for the Open Web", + expected: [ + [7, 1], + [10, 17], + ], + }, + { + tokens: ["\u9996"], + phrase: "Test \u9996\u9875 Test", + expected: [ + [0, 5], + [6, 6], + ], + }, + { + tokens: ["mo", "zilla"], + phrase: "mOzIlLa", + expected: [[2, 5]], + }, + { + tokens: ["MO", "ZILLA"], + phrase: "mozilla", + expected: [[2, 5]], + }, + { + tokens: [""], // Should never happen in practice. + phrase: "mozilla", + expected: [[0, 7]], + }, + { + tokens: ["mo", "om", "la"], + phrase: "mozilla mozzarella momo", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + { + tokens: ["mo", "om", "la"], + phrase: "MOZILLA MOZZARELLA MOMO", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + { + tokens: ["MO", "OM", "LA"], + phrase: "mozilla mozzarella momo", + expected: [ + [2, 6], + [10, 9], + [21, 2], + ], + }, + ]; + for (let { tokens, phrase, expected } of tests) { + tokens = tokens.map(t => ({ + value: t, + lowerCaseValue: t.toLocaleLowerCase(), + })); + Assert.deepEqual( + UrlbarUtils.getTokenMatches( + tokens, + phrase, + UrlbarUtils.HIGHLIGHT.SUGGESTED + ), + expected, + `Match "${tokens.map(t => t.value).join(", ")}" on "${phrase}"` + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js b/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js new file mode 100644 index 0000000000..6efc6711c6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_unEscapeURIForUI.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test for unEscapeURIForUI function in UrlbarUtils. + */ + +"use strict"; + +const TEST_DATA = [ + { + description: "Test for characters including percent encoded chars", + input: "A%E3%81%82%F0%A0%AE%B7%21", + expected: "Aあ𠮷!", + testMessage: "Unescape given characters correctly", + }, + { + description: "Test for characters over the limit", + input: "A%E3%81%82%F0%A0%AE%B7%21".repeat( + Math.ceil(UrlbarUtils.MAX_TEXT_LENGTH / 25) + ), + expected: "A%E3%81%82%F0%A0%AE%B7%21".repeat( + Math.ceil(UrlbarUtils.MAX_TEXT_LENGTH / 25) + ), + testMessage: "Return given characters as it is because of over the limit", + }, +]; + +add_task(function () { + for (const { description, input, expected, testMessage } of TEST_DATA) { + info(description); + + const result = UrlbarUtils.unEscapeURIForUI(input); + Assert.equal(result, expected, testMessage); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_about_urls.js b/browser/components/urlbar/tests/unit/test_about_urls.js new file mode 100644 index 0000000000..277ddb8ee1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_about_urls.js @@ -0,0 +1,176 @@ +/* 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/. */ + +"use strict"; + +const { AboutPagesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/AboutPagesUtils.sys.mjs" +); + +testEngine_setup(); + +// "about:ab" should match "about:about" +add_task(async function aboutAb() { + let context = createContext("about:ab", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:Ab" should match "about:about" +add_task(async function aboutAb() { + let context = createContext("about:Ab", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:About", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:about" should match "about:about" +add_task(async function aboutAbout() { + let context = createContext("about:about", { isPrivate: false }); + await check_results({ + context, + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + ], + }); +}); + +// "about:a" should complete to "about:about" and also match "about:addons" +add_task(async function aboutAboutAndAboutAddons() { + let context = createContext("about:a", { isPrivate: false }); + await check_results({ + context, + search: "about:a", + autofilled: "about:about", + completed: "about:about", + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + }), + makeVisitResult(context, { + uri: "about:addons", + title: "about:addons", + tags: null, + providerName: "AboutPages", + }), + ], + }); +}); + +// "about:" by itself matches a list of about: pages and nothing else +add_task(async function aboutColonMatchesOnlyAboutPages() { + // We generate 9 about: page results because there are 10 results total, + // and the first result is the heuristic result. + function getFirst9AboutPages() { + const aboutPageNames = AboutPagesUtils.visibleAboutUrls.slice(0, 9); + const aboutPageResults = aboutPageNames.map(aboutPageName => { + return makeVisitResult(context, { + uri: aboutPageName, + title: aboutPageName, + tags: null, + providerName: "AboutPages", + }); + }); + return aboutPageResults; + } + + let context = createContext("about:", { isPrivate: false }); + await check_results({ + context, + search: "about:", + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + heuristic: true, + }), + ...getFirst9AboutPages(), + ], + }); +}); + +// Results for about: pages do not match webpage titles from the user's history +add_task(async function aboutResultsDoNotMatchTitlesInHistory() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/guide/config/"), + title: "Guide to config in Firefox", + }, + ]); + + let context = createContext("about:config", { isPrivate: false }); + await check_results({ + context, + search: "about:config", + matches: [ + makeVisitResult(context, { + uri: "about:config", + title: "about:config", + heuristic: true, + providerName: "Autofill", + }), + ], + }); +}); + +// Tests that about: pages are shown after general results. +add_task(async function after_general() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/guide/aboutaddons/"), + title: "Guide to about:addons in Firefox", + }, + ]); + + let context = createContext("about:a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "about:about", + title: "about:about", + heuristic: true, + providerName: "Autofill", + }), + makeVisitResult(context, { + uri: "http://example.com/guide/aboutaddons/", + title: "Guide to about:addons in Firefox", + }), + makeVisitResult(context, { + uri: "about:addons", + title: "about:addons", + tags: null, + providerName: "AboutPages", + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js b/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js new file mode 100644 index 0000000000..94b29b913a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_adaptiveHistory.js @@ -0,0 +1,1441 @@ +/* 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/. */ + +"use strict"; + +// Test for adaptive history autofill. + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +const TEST_DATA = [ + { + description: "Basic behavior for adaptive history autofill", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "URL that has www", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "User's input starts with www", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "www.exa" }], + userInput: "www.exa", + expected: { + autofilled: "www.example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Case differences for user's input are ignored", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "EXA" }], + userInput: "eXA", + expected: { + autofilled: "eXAmple.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Case differences for user's input that starts with www are ignored", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "www.exa" }], + userInput: "WWW.exa", + expected: { + autofilled: "WWW.example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Mutiple case difference input history", + pref: true, + visitHistory: ["http://example.com/yes", "http://example.com/no"], + inputHistory: [ + { uri: "http://example.com/yes", input: "exa" }, + { uri: "http://example.com/yes", input: "EXA" }, + { uri: "http://example.com/yes", input: "EXa" }, + { uri: "http://example.com/yes", input: "eXa" }, + { uri: "http://example.com/yes", input: "eXA" }, + { uri: "http://example.com/no", input: "exa" }, + { uri: "http://example.com/no", input: "exa" }, + { uri: "http://example.com/no", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/yes", + completed: "http://example.com/yes", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/yes", + title: "test visit for http://example.com/yes", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/no", + title: "test visit for http://example.com/no", + }), + ], + }, + }, + { + description: "Multiple input history count", + pref: true, + visitHistory: ["http://example.com/few", "http://example.com/many"], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + { uri: "http://example.com/many", input: "examp" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: "Multiple input history count with same input", + pref: true, + visitHistory: ["http://example.com/few", "http://example.com/many"], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + { uri: "http://example.com/many", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: + "Multiple input history count with same input but different frecency", + pref: true, + visitHistory: [ + "http://example.com/few", + "http://example.com/many", + "http://example.com/many", + ], + inputHistory: [ + { uri: "http://example.com/many", input: "exa" }, + { uri: "http://example.com/few", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/many", + completed: "http://example.com/many", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/many", + title: "test visit for http://example.com/many", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/few", + title: "test visit for http://example.com/few", + }), + ], + }, + }, + { + description: "User input is shorter than the input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "e", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "User input is longer than the input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "User input starts with input history and includes path of the url", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.com/te", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "User input starts with input history and but another url", + pref: true, + visitHistory: ["http://example.com/test", "http://example.org/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "example.o", + expected: { + autofilled: "example.org/", + completed: "http://example.org/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.org/", + fallbackTitle: "example.org", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.org/test", + title: "test visit for http://example.org/test", + }), + ], + }, + }, + { + description: "User input does not start with input history", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "notmatch" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "User input does not start with input history, but it includes as part of URL", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "test", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "User input does not start with visited URL", + pref: true, + visitHistory: ["http://mozilla.com/test"], + inputHistory: [{ uri: "http://mozilla.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://mozilla.com/test", + title: "test visit for http://mozilla.com/test", + }), + ], + }, + }, + { + description: "Visited page is bookmarked", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test bookmark", + heuristic: true, + }), + ], + }, + }, + { + description: "Visit history and no bookamrk with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Visit history and no bookamrk with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "Bookmarked visit history with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test", "http://example.com/bookmarked"], + bookmarks: [ + { uri: "http://example.com/bookmarked", title: "test bookmark" }, + ], + inputHistory: [ + { + uri: "http://example.com/test", + input: "exa", + }, + { + uri: "http://example.com/bookmarked", + input: "exa", + }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/bookmarked", + completed: "http://example.com/bookmarked", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/bookmarked", + title: "test bookmark", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Bookmarked visit history with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + visitHistory: ["http://example.com/test", "http://example.com/bookmarked"], + bookmarks: [ + { uri: "http://example.com/bookmarked", title: "test bookmark" }, + ], + inputHistory: [ + { + uri: "http://example.com/test", + input: "exa", + }, + { + uri: "http://example.com/bookmarked", + input: "exa", + }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/bookmarked", + completed: "http://example.com/bookmarked", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/bookmarked", + title: "test bookmark", + heuristic: true, + }), + ], + }, + }, + { + description: "No visit history with HISTORY source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "No visit history with BOOKMARK source", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + bookmarks: [{ uri: "http://example.com/bookmarked", title: "test" }], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: "Match with path expression", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [{ uri: "http://example.com/test", input: "example.com/te" }], + userInput: "example.com/te", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and the same string for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http://example.com/test", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and URL expression for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/te" }, + ], + userInput: "http://example.com/te", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + }), + ], + }, + }, + { + description: + "Prefixed URL for input history and path expression for user input", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "http://example.com/te" }, + ], + userInput: "example.com/te", + expected: { + autofilled: "example.com/testMany", + completed: "http://example.com/testMany", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/testMany", + title: "test visit for http://example.com/testMany", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http" }], + userInput: "http", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http:' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http:" }], + userInput: "http:", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http:/' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http:/" }], + userInput: "http:/", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http://' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http://" }], + userInput: "http://", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Prefixed URL for input history and 'http://e' for user input", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "http://e" }], + userInput: "http://e", + expected: { + autofilled: "http://example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Prefixed URL with www omitted for input history and 'http://e' for user input", + pref: true, + visitHistory: ["http://www.example.com/test"], + inputHistory: [{ uri: "http://www.example.com/test", input: "http://e" }], + userInput: "http://e", + expected: { + autofilled: "http://example.com/test", + completed: "http://www.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/test", + title: "test visit for http://www.example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: + "Those that match with fixed URL take precedence over those that match prefixed URL", + pref: true, + visitHistory: ["http://http.example.com/test", "http://example.com/test"], + inputHistory: [ + { uri: "http://http.example.com/test", input: "http" }, + { uri: "http://example.com/test", input: "http://example.com/test" }, + ], + userInput: "http", + expected: { + autofilled: "http.example.com/test", + completed: "http://http.example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://http.example.com/test", + title: "test visit for http://http.example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Input history is totally different string from the URL", + pref: true, + visitHistory: [ + "http://example.com/testMany", + "http://example.com/testMany", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/test", input: "totally-different-string" }, + ], + userInput: "totally", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "Input history is totally different string from the URL and there is a visit history whose URL starts with the input", + pref: true, + visitHistory: ["http://example.com/test", "http://totally.example.com"], + inputHistory: [ + { uri: "http://example.com/test", input: "totally-different-string" }, + ], + userInput: "totally", + expected: { + autofilled: "totally.example.com/", + completed: "http://totally.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://totally.example.com/", + title: "test visit for http://totally.example.com/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Use count threshold is as same as use count of input history", + pref: true, + useCountThreshold: 1 * 0.9 + 1, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Use count threshold is less than use count of input history", + pref: true, + useCountThreshold: 3, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "Use count threshold is more than use count of input history", + pref: true, + useCountThreshold: 10, + visitHistory: ["http://example.com/test"], + inputHistory: [ + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + { uri: "http://example.com/test", input: "exa" }, + ], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "minCharsThreshold pref equals to the user input length", + pref: true, + minCharsThreshold: 3, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "minCharsThreshold pref is smaller than the user input length", + pref: true, + minCharsThreshold: 2, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }, + }, + { + description: "minCharsThreshold pref is larger than the user input length", + pref: true, + minCharsThreshold: 4, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "Prioritize path component with case-sensitive and that is visited", + pref: true, + visitHistory: [ + "http://example.com/TEST", + "http://example.com/TEST", + "http://example.com/test", + ], + inputHistory: [ + { uri: "http://example.com/TEST", input: "example.com/test" }, + { uri: "http://example.com/test", input: "example.com/test" }, + ], + userInput: "example.com/test", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/TEST", + title: "test visit for http://example.com/TEST", + }), + ], + }, + }, + { + description: + "Prioritize path component with case-sensitive but no visited data", + pref: true, + visitHistory: ["http://example.com/TEST"], + inputHistory: [ + { uri: "http://example.com/TEST", input: "example.com/test" }, + ], + userInput: "example.com/test", + expected: { + autofilled: "example.com/test", + completed: "http://example.com/test", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/test", + fallbackTitle: "example.com/test", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/TEST", + title: "test visit for http://example.com/TEST", + }), + ], + }, + }, + { + description: + "With history and bookmarks sources, foreign_count == 0, frecency <= 0: No adaptive history autofill", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: + "With history source, visit_count == 0, foreign_count != 0: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + userInput: "exa", + expected: { + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }, + }, + { + description: + "With history source, visit_count > 0, foreign_count != 0, frecency <= 20: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + bookmarks: [{ uri: "http://example.com/test", title: "test bookmark" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + ], + }, + }, + { + description: + "With history source, visit_count > 0, foreign_count == 0, frecency <= 20: No adaptive history autofill", + pref: true, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + frecency: 0, + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Empty input string", + pref: true, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, + { + description: "Turn the pref off", + pref: false, + visitHistory: ["http://example.com/test"], + inputHistory: [{ uri: "http://example.com/test", input: "exa" }], + userInput: "exa", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }, + }, +]; + +add_task(async function inputTest() { + for (const { + description, + pref, + minCharsThreshold, + useCountThreshold, + source, + visitHistory, + inputHistory, + bookmarks, + frecency, + userInput, + expected, + } of TEST_DATA) { + info(description); + + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", pref); + + if (!isNaN(minCharsThreshold)) { + UrlbarPrefs.set( + "autoFill.adaptiveHistory.minCharsThreshold", + minCharsThreshold + ); + } + + if (!isNaN(useCountThreshold)) { + UrlbarPrefs.set( + "autoFill.adaptiveHistory.useCountThreshold", + useCountThreshold + ); + } + + if (visitHistory && visitHistory.length) { + await PlacesTestUtils.addVisits(visitHistory); + } + for (const { uri, input } of inputHistory) { + await UrlbarUtils.addToInputHistory(uri, input); + } + for (const bookmark of bookmarks || []) { + await PlacesTestUtils.addBookmarkWithDetails(bookmark); + } + + if (typeof frecency == "number") { + await PlacesUtils.withConnectionWrapper("test::setFrecency", db => + db.execute( + `UPDATE moz_places SET frecency = :frecency WHERE url = :url`, + { + frecency, + url: visitHistory[0], + } + ) + ); + } + + const sources = source + ? [source] + : [ + UrlbarUtils.RESULT_SOURCE.HISTORY, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ]; + + const context = createContext(userInput, { + sources, + isPrivate: false, + }); + + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + hasAutofillTitle: expected.hasAutofillTitle, + matches: expected.results.map(f => f(context)), + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.minCharsThreshold"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.useCountThreshold"); + } +}); + +add_task(async function urlCase() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + const testVisitFixed = "example.com/ABC/DEF"; + const testVisitURL = `http://${testVisitFixed}`; + const testInput = "example"; + await PlacesTestUtils.addVisits([testVisitURL]); + await UrlbarUtils.addToInputHistory(testVisitURL, testInput); + + const userInput = "example.COM/abc/def"; + for (let i = 1; i <= userInput.length; i++) { + const currentUserInput = userInput.substring(0, i); + const context = createContext(currentUserInput, { isPrivate: false }); + + if (currentUserInput.length < testInput.length) { + // Autofill with host. + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }); + } else if (currentUserInput.length !== testVisitFixed.length) { + // Autofill using input history. + const autofilled = currentUserInput + testVisitFixed.substring(i); + await check_results({ + context, + autofilled, + completed: "http://example.com/ABC/DEF", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }); + } else { + // Autofill using user's input. + await check_results({ + context, + autofilled: "example.COM/abc/def", + completed: "http://example.com/abc/def", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: "example.com/abc/def", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }); + } + } + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); +}); + +add_task(async function decayTest() { + UrlbarPrefs.set("autoFill.adaptiveHistory.enabled", true); + + await PlacesTestUtils.addVisits(["http://example.com/test"]); + await UrlbarUtils.addToInputHistory("http://example.com/test", "exa"); + + const initContext = createContext("exa", { isPrivate: false }); + await check_results({ + context: initContext, + autofilled: "example.com/test", + completed: "http://example.com/test", + matches: [ + makeVisitResult(initContext, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }); + + // The decay rate for a day is 0.975, as defined in PlacesFrecencyRecalculator + // Therefore, after 30 days, as use_count will be 0.975^30 = 0.468, we set the + // useCountThreshold 0.47 to not take the input history passed 30 days. + UrlbarPrefs.set("autoFill.adaptiveHistory.useCountThreshold", 0.47); + + // Make 29 days later. + for (let i = 0; i < 29; i++) { + await Cc["@mozilla.org/places/frecency-recalculator;1"] + .getService(Ci.nsIObserver) + .wrappedJSObject.decay(); + } + const midContext = createContext("exa", { isPrivate: false }); + await check_results({ + context: midContext, + autofilled: "example.com/test", + completed: "http://example.com/test", + matches: [ + makeVisitResult(midContext, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + heuristic: true, + }), + ], + }); + + // Total 30 days later. + await Cc["@mozilla.org/places/frecency-recalculator;1"] + .getService(Ci.nsIObserver) + .wrappedJSObject.decay(); + const context = createContext("exa", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/test", + title: "test visit for http://example.com/test", + }), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("autoFill.adaptiveHistory.enabled"); + UrlbarPrefs.clear("autoFill.adaptiveHistory.useCountThreshold"); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js b/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js new file mode 100644 index 0000000000..9b8b77e82c --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js @@ -0,0 +1,150 @@ +/* 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/. */ + +// This is a specific autofill test to ensure we pick the correct bookmarked +// state of an origin. Regardless of the order of origins, we should always pick +// the correct bookmarked status. + +add_task(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + let host = "example.com"; + // Add a bookmark to the http version, but ensure the https version has an + // higher frecency. + let bookmark = await PlacesUtils.bookmarks.insert({ + url: `http://${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits(`https://${host}`); + } + // ensure both fall below the threshold. + for (let i = 0; i < 15; i++) { + await PlacesTestUtils.addVisits(`https://not-${host}`); + } + + async function check_autofill() { + let threshold = await getOriginAutofillThreshold(); + let httpOriginFrecency = await getOriginFrecency("http://", host); + Assert.less( + httpOriginFrecency, + threshold, + "Http origin frecency should be below the threshold" + ); + let httpsOriginFrecency = await getOriginFrecency("https://", host); + Assert.less( + httpsOriginFrecency, + threshold, + "Https origin frecency should be below the threshold" + ); + Assert.less( + httpOriginFrecency, + httpsOriginFrecency, + "Http origin frecency should be below the https origin frecency" + ); + + // The http version should be filled because it's bookmarked, but with the + // https prefix that is more frecent. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://${host}/`, + title: `test visit for https://${host}/`, + heuristic: true, + }), + makeVisitResult(context, { + uri: `https://not-${host}/`, + title: `test visit for https://not-${host}/`, + }), + ], + }); + } + + await check_autofill(); + + // Now remove the bookmark, ensure to remove the orphans, then reinsert the + // bookmark; thus we physically invert the order of the rows in the table. + await checkOriginsOrder(host, ["http://", "https://"]); + await PlacesUtils.bookmarks.remove(bookmark); + await PlacesUtils.withConnectionWrapper("removeOrphans", async db => { + db.execute(`DELETE FROM moz_places WHERE url = :url`, { + url: `http://${host}/`, + }); + db.execute( + `DELETE FROM moz_origins WHERE prefix = "http://" AND host = :host`, + { host } + ); + }); + bookmark = await PlacesUtils.bookmarks.insert({ + url: `http://${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + await checkOriginsOrder(host, ["https://", "http://"]); + + await check_autofill(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.remove(bookmark); +}); + +add_task(async function test_www() { + // Add a bookmark to the www version + let host = "example.com"; + await PlacesUtils.bookmarks.insert({ + url: `http://www.${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + + info("search for start of www."); + let context = createContext("w", { isPrivate: false }); + await check_results({ + context, + autofilled: `www.${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: `www.${host}`, + heuristic: true, + }), + ], + }); + info("search for full www."); + context = createContext("www.", { isPrivate: false }); + await check_results({ + context, + autofilled: `www.${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: `www.${host}`, + heuristic: true, + }), + ], + }); + info("search for host without www."); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `http://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `http://www.${host}/`, + fallbackTitle: `www.${host}`, + heuristic: true, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js b/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js new file mode 100644 index 0000000000..5782bca210 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_do_not_trim.js @@ -0,0 +1,140 @@ +/* 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/. */ + +// We should not autofill when the search string contains spaces. + +testEngine_setup(); + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/link/"), + }); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + await cleanupPlaces(); + }); +}); + +add_task(async function test_not_autofill_ws_1() { + info("Do not autofill whitespaced entry 1"); + let context = createContext("mozilla.org ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_2() { + info("Do not autofill whitespaced entry 2"); + let context = createContext("mozilla.org/ ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "http://mozilla.org/", + iconUri: "page-icon:http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_3() { + info("Do not autofill whitespaced entry 3"); + let context = createContext("mozilla.org/link ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/link", + fallbackTitle: "http://mozilla.org/link", + iconUri: "page-icon:http://mozilla.org/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_4() { + info( + "Do not autofill whitespaced entry 4, but UrlbarProviderPlaces provides heuristic result" + ); + let context = createContext("mozilla.org/link/ ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + iconUri: "page-icon:http://mozilla.org/link/", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_5() { + info("Do not autofill whitespaced entry 5"); + let context = createContext("moz illa ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: "moz illa ", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); + +add_task(async function test_not_autofill_ws_6() { + info("Do not autofill whitespaced entry 6"); + let context = createContext(" mozilla", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " mozilla", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/link/", + title: "test visit for http://mozilla.org/link/", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_functional.js b/browser/components/urlbar/tests/unit/test_autofill_functional.js new file mode 100644 index 0000000000..632a0d580d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_functional.js @@ -0,0 +1,115 @@ +/* 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/. */ + +// Functional tests for inline autocomplete + +add_task(async function setup() { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); +}); + +add_task(async function test_urls_order() { + info("Add urls, check for correct order"); + let places = [ + { uri: Services.io.newURI("http://visit1.mozilla.org") }, + { uri: Services.io.newURI("http://visit2.mozilla.org") }, + ]; + await PlacesTestUtils.addVisits(places); + let context = createContext("vis", { isPrivate: false }); + await check_results({ + context, + autofilled: "visit2.mozilla.org/", + completed: "http://visit2.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://visit2.mozilla.org/", + title: "test visit for http://visit2.mozilla.org/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://visit1.mozilla.org/", + title: "test visit for http://visit1.mozilla.org/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_bookmark_first() { + info("With a bookmark and history, the query result should be the bookmark"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://bookmark1.mozilla.org/"), + }); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://bookmark1.mozilla.org/foo") + ); + let context = createContext("bookmark", { isPrivate: false }); + await check_results({ + context, + autofilled: "bookmark1.mozilla.org/", + completed: "http://bookmark1.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://bookmark1.mozilla.org/", + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://bookmark1.mozilla.org/foo", + title: "test visit for http://bookmark1.mozilla.org/foo", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_complete_querystring() { + info("Check to make sure we autocomplete after ?"); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://smokey.mozilla.org/foo?bacon=delicious") + ); + let context = createContext("smokey.mozilla.org/foo?", { isPrivate: false }); + await check_results({ + context, + autofilled: "smokey.mozilla.org/foo?bacon=delicious", + completed: "http://smokey.mozilla.org/foo?bacon=delicious", + matches: [ + makeVisitResult(context, { + uri: "http://smokey.mozilla.org/foo?bacon=delicious", + title: "test visit for http://smokey.mozilla.org/foo?bacon=delicious", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_complete_fragment() { + info("Check to make sure we autocomplete after #"); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://smokey.mozilla.org/foo?bacon=delicious#bar") + ); + let context = createContext("smokey.mozilla.org/foo?bacon=delicious#bar", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "smokey.mozilla.org/foo?bacon=delicious#bar", + completed: "http://smokey.mozilla.org/foo?bacon=delicious#bar", + matches: [ + makeVisitResult(context, { + uri: "http://smokey.mozilla.org/foo?bacon=delicious#bar", + title: + "test visit for http://smokey.mozilla.org/foo?bacon=delicious#bar", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_origins.js b/browser/components/urlbar/tests/unit/test_autofill_origins.js new file mode 100644 index 0000000000..6913b1a3b9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_origins.js @@ -0,0 +1,1041 @@ +/* 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/. */ + +"use strict"; + +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; + +const origin = "example.com"; + +async function cleanup() { + let suggestPrefs = ["history", "bookmark", "openpage"]; + for (let type of suggestPrefs) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + } + await cleanupPlaces(); +} + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +// "example.com/" should match http://example.com/. i.e., the search string +// should be treated as if it didn't have the trailing slash. +add_task(async function trailingSlash() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + + let context = createContext(`${origin}/`, { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: `http://${origin}/`, + matches: [ + makeVisitResult(context, { + uri: `http://${origin}/`, + title: `test visit for http://${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com/" should match http://www.example.com/. i.e., the search string +// should be treated as if it didn't have the trailing slash. +add_task(async function trailingSlashWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/", + }, + ]); + let context = createContext(`${origin}/`, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: `http://www.${origin}/`, + title: `test visit for http://www.${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match http://example.com:8888/, and the port should be completed. +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com:8" should match http://example.com:8888/, and the port should +// be completed. +add_task(async function portPartial() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:8`, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EXaM" should match http://example.com/ and the case of the search string +// should be preserved in the autofilled value. +add_task(async function preserveCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + let context = createContext("EXaM", { isPrivate: false }); + await check_results({ + context, + autofilled: "EXaMple.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}/`, + title: `test visit for http://${origin}/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EXaM" should match http://example.com:8888/, the port should be completed, +// and the case of the search string should be preserved in the autofilled +// value. +add_task(async function preserveCasePort() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext("EXaM", { isPrivate: false }); + await check_results({ + context, + autofilled: "EXaMple.com:8888/", + completed: "http://example.com:8888/", + matches: [ + makeVisitResult(context, { + uri: `http://${origin}:8888/`, + title: `test visit for http://${origin}:8888/`, + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "example.com:89" should *not* match http://example.com:8888/. +add_task(async function portNoMatch1() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:89`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${origin}:89/`, + fallbackTitle: `http://${origin}:89/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "example.com:9" should *not* match http://example.com:8888/. +add_task(async function portNoMatch2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/", + }, + ]); + let context = createContext(`${origin}:9`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${origin}:9/`, + fallbackTitle: `http://${origin}:9/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "example/" should *not* match http://example.com/. +add_task(async function trailingSlash_2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + }, + ]); + let context = createContext("example/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example/", + fallbackTitle: "http://example/", + iconUri: "page-icon:http://example/", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// multi.dotted.domain, search up to dot. +add_task(async function multidotted() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.co.jp:8888/", + }, + ]); + let context = createContext("www.example.co.", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.co.jp:8888/", + completed: "http://www.example.co.jp:8888/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.co.jp:8888/", + title: "test visit for http://www.example.co.jp:8888/", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +add_task(async function test_ip() { + // IP addresses have complicated rules around whether they show + // HeuristicFallback's backup search result. Flip this pref to disable that + // backup search and simplify ths subtest. + Services.prefs.setBoolPref("keyword.enabled", false); + for (let str of [ + "192.168.1.1/", + "255.255.255.255:8080/", + "[2001:db8::1428:57ab]/", + "[::c0a8:5909]/", + "[::1]/", + ]) { + info("testing " + str); + await PlacesTestUtils.addVisits("http://" + str); + for (let i = 1; i < str.length; ++i) { + let context = createContext(str.substring(0, i), { isPrivate: false }); + await check_results({ + context, + autofilled: str, + completed: "http://" + str, + matches: [ + makeVisitResult(context, { + uri: "http://" + str, + title: `test visit for http://${str}`, + heuristic: true, + }), + ], + }); + } + await cleanup(); + } + Services.prefs.clearUserPref("keyword.enabled"); +}); + +// host starting with large number. +add_task(async function large_number_host() { + await PlacesTestUtils.addVisits([ + { + uri: "http://12345example.it:8888/", + }, + ]); + let context = createContext("1234", { isPrivate: false }); + await check_results({ + context, + autofilled: "12345example.it:8888/", + completed: "http://12345example.it:8888/", + matches: [ + makeVisitResult(context, { + uri: "http://12345example.it:8888/", + title: "test visit for http://12345example.it:8888/", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// When determining which origins should be autofilled, all the origins sharing +// a host should be added together to get their combined frecency -- i.e., +// prefixes should be collapsed. And then from that list, the origin with the +// highest frecency should be chosen. +add_task(async function groupByHost() { + // Add some visits to the same host, example.com. Add one http and two https + // so that https has a higher frecency and is therefore the origin that should + // be autofilled. Also add another origin that has a higher frecency than + // both so that alone, neither http nor https would be autofilled, but added + // together they should be. + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + ]); + + let httpFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://example.com/" } + ); + let httpsFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "https://example.com/" } + ); + let otherFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "https://mozilla.org/" } + ); + Assert.less(httpFrec, httpsFrec, "Sanity check"); + Assert.less(httpsFrec, otherFrec, "Sanity check"); + + // Make sure the frecencies of the three origins are as expected in relation + // to the threshold. + let threshold = await getOriginAutofillThreshold(); + Assert.less(httpFrec, threshold, "http origin should be < threshold"); + Assert.less(httpsFrec, threshold, "https origin should be < threshold"); + Assert.ok(threshold <= otherFrec, "Other origin should cross threshold"); + + Assert.ok( + threshold <= httpFrec + httpsFrec, + "http and https origin added together should cross threshold" + ); + + // The https origin should be autofilled. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// This is the same as the previous (groupByHost), but it changes the standard +// deviation multiplier by setting the corresponding pref. This makes sure that +// the pref is respected. +add_task(async function groupByHostNonDefaultStddevMultiplier() { + let stddevMultiplier = 1.5; + Services.prefs.setCharPref( + "browser.urlbar.autoFill.stddevMultiplier", + Number(stddevMultiplier).toFixed(1) + ); + + await PlacesTestUtils.addVisits([ + { uri: "http://example.com/" }, + { uri: "http://example.com/" }, + + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + { uri: "https://example.com/" }, + + { uri: "https://foo.com/" }, + { uri: "https://foo.com/" }, + { uri: "https://foo.com/" }, + + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + { uri: "https://mozilla.org/" }, + ]); + + let httpFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "http://example.com/", + } + ); + let httpsFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "https://example.com/", + } + ); + let otherFrec = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { + url: "https://mozilla.org/", + } + ); + Assert.less(httpFrec, httpsFrec, "Sanity check"); + Assert.less(httpsFrec, otherFrec, "Sanity check"); + + // Make sure the frecencies of the three origins are as expected in relation + // to the threshold. + let threshold = await getOriginAutofillThreshold(); + Assert.less(httpFrec, threshold, "http origin should be < threshold"); + Assert.less(httpsFrec, threshold, "https origin should be < threshold"); + Assert.ok(threshold <= otherFrec, "Other origin should cross threshold"); + + Assert.ok( + threshold <= httpFrec + httpsFrec, + "http and https origin added together should cross threshold" + ); + + // The https origin should be autofilled. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.autoFill.stddevMultiplier"); + + await cleanup(); +}); + +// This is similar to suggestHistoryFalse_bookmark_0 in test_autofill_tasks.js, +// but it adds unbookmarked visits for multiple URLs with the same origin. +add_task(async function suggestHistoryFalse_bookmark_multiple() { + // Force only bookmarked pages to be suggested and therefore only bookmarked + // pages to be completed. + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + let search = "ex"; + let baseURL = "http://example.com/"; + let bookmarkedURL = baseURL + "bookmarked"; + + // Add visits for three different URLs all sharing the same origin, and then + // bookmark the second one. After that, the origin should be autofilled. The + // reason for adding unbookmarked visits before and after adding the + // bookmarked visit is to make sure our aggregate SQL query for determining + // whether an origin is bookmarked is correct. + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other1", + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: bookmarkedURL, + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other2", + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + ], + }); + + // Now bookmark the second URL. It should be suggested and completed. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: bookmarkedURL, + }); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: baseURL, + matches: [ + makeVisitResult(context, { + uri: baseURL, + fallbackTitle: "example.com", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); + +// This is similar to suggestHistoryFalse_bookmark_prefix_0 in +// autofill_test_autofill_originsAndQueries.js, but it adds unbookmarked visits +// for multiple URLs with the same origin. +add_task(async function suggestHistoryFalse_bookmark_prefix_multiple() { + // Force only bookmarked pages to be suggested and therefore only bookmarked + // pages to be completed. + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + let search = "http://ex"; + let baseURL = "http://example.com/"; + let bookmarkedURL = baseURL + "bookmarked"; + + // Add visits for three different URLs all sharing the same origin, and then + // bookmark the second one. After that, the origin should be autofilled. The + // reason for adding unbookmarked visits before and after adding the + // bookmarked visit is to make sure our aggregate SQL query for determining + // whether an origin is bookmarked is correct. + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other1", + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: bookmarkedURL, + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + await PlacesTestUtils.addVisits([ + { + uri: baseURL + "other2", + }, + ]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${search}/`, + fallbackTitle: `${search}/`, + iconUri: "", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + + // Now bookmark the second URL. It should be suggested and completed. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: bookmarkedURL, + }); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://example.com/", + completed: baseURL, + matches: [ + makeVisitResult(context, { + uri: baseURL, + fallbackTitle: "example.com", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); + +// When the autofilled URL is `example.com/`, a visit for `example.com/?` should +// not be included in the results since it dupes the autofill result. +add_task(async function searchParams() { + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/?", + "http://example.com/?foo", + ]); + + // First, do a search with autofill disabled to make sure the visits were + // properly added. `example.com/?foo` has the highest frecency because it was + // added last; `example.com/?` has the next highest. `example.com/` dupes + // `example.com/?`, so it should not appear. + UrlbarPrefs.set("autoFill", false); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/?foo", + title: "test visit for http://example.com/?foo", + }), + makeVisitResult(context, { + uri: "http://example.com/?", + title: "test visit for http://example.com/?", + }), + ], + }); + + // Now do a search with autofill enabled. This time `example.com/` will be + // autofilled, and since `example.com/?` dupes it, `example.com/?` should not + // appear. + UrlbarPrefs.clear("autoFill"); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "http://example.com/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "test visit for http://example.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com/?foo", + title: "test visit for http://example.com/?foo", + }), + ], + }); + + await cleanup(); +}); + +// When the autofilled URL is `example.com/`, a visit for `example.com/?` should +// not be included in the results since it dupes the autofill result. (Same as +// the previous task but with https URLs instead of http. There shouldn't be any +// substantive difference.) +add_task(async function searchParams_https() { + await PlacesTestUtils.addVisits([ + "https://example.com/", + "https://example.com/?", + "https://example.com/?foo", + ]); + + // First, do a search with autofill disabled to make sure the visits were + // properly added. `example.com/?foo` has the highest frecency because it was + // added last; `example.com/?` has the next highest. `example.com/` dupes + // `example.com/?`, so it should not appear. + UrlbarPrefs.set("autoFill", false); + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/?foo", + title: "test visit for https://example.com/?foo", + }), + makeVisitResult(context, { + uri: "https://example.com/?", + title: "test visit for https://example.com/?", + }), + ], + }); + + // Now do a search with autofill enabled. This time `example.com/` will be + // autofilled, and since `example.com/?` dupes it, `example.com/?` should not + // appear. + UrlbarPrefs.clear("autoFill"); + context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/?foo", + title: "test visit for https://example.com/?foo", + }), + ], + }); + + await cleanup(); +}); + +// Checks an origin that looks like a prefix: a scheme with no dots + a port. +add_task(async function originLooksLikePrefix() { + let hostAndPort = "localhost:8888"; + let address = `http://${hostAndPort}/`; + await PlacesTestUtils.addVisits([{ uri: address }]); + + // addTestSuggestionsEngine adds a search engine + // with localhost as a server, so we have to disable the + // TTS result or else it will show up as a second result + // when searching l to localhost + UrlbarPrefs.set("suggest.engines", false); + + for (let search of ["lo", "localhost", "localhost:", "localhost:8888"]) { + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: hostAndPort + "/", + completed: address, + matches: [ + makeVisitResult(context, { + uri: address, + title: `test visit for http://${hostAndPort}/`, + heuristic: true, + }), + ], + }); + } + await cleanup(); +}); + +// Checks an origin whose prefix is "about:". +add_task(async function about() { + const testData = [ + { + uri: "about:config", + input: "conf", + results: [ + context => + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + context => + makeBookmarkResult(context, { + uri: "about:config", + title: "A bookmark", + }), + ], + }, + { + uri: "about:blank", + input: "about:blan", + results: [ + context => + makeVisitResult(context, { + uri: "about:blan", + fallbackTitle: "about:blan", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + context => + makeBookmarkResult(context, { + uri: "about:blank", + title: "A bookmark", + }), + ], + }, + ]; + + for (const { uri, input, results } of testData) { + await PlacesTestUtils.addBookmarkWithDetails({ uri }); + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: results.map(f => f(context)), + }); + await cleanup(); + } +}); + +// Checks an origin whose prefix is "place:". +add_task(async function place() { + const testData = [ + { + uri: "place:transition=7&sort=4", + input: "tran", + }, + { + uri: "place:transition=7&sort=4", + input: "place:tran", + }, + ]; + + for (const { uri, input } of testData) { + await PlacesTestUtils.addBookmarkWithDetails({ uri }); + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }); + await cleanup(); + } +}); + +add_task(async function nullTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + // Set title of visits data to an empty string causes + // the title to be null in the database. + title: "", + frecency: 100, + }, + { + uri: "https://www.example.com/", + title: "high frecency", + frecency: 50, + }, + { + uri: "http://www.example.com/", + title: "low frecency", + frecency: 1, + }, + ], + input: "example.com", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "high frecency", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "high frecency", + }), + ], + }, + }); +}); + +add_task(async function domainTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + title: "example.com", + frecency: 100, + }, + { + uri: "https://www.example.com/", + title: "", + frecency: 50, + }, + { + uri: "http://www.example.com/", + title: "lowest frecency but has title", + frecency: 1, + }, + ], + input: "example.com", + expected: { + autofilled: "example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "lowest frecency but has title", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "www.example.com", + }), + ], + }, + }); +}); + +add_task(async function exactMatchedTitle() { + await doTitleTest({ + visits: [ + { + uri: "http://example.com/", + title: "exact match", + frecency: 50, + }, + { + uri: "https://www.example.com/", + title: "high frecency uri", + frecency: 100, + }, + ], + input: "http://example.com/", + expected: { + autofilled: "http://example.com/", + completed: "http://example.com/", + matches: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "exact match", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "high frecency uri", + }), + ], + }, + }); +}); + +async function doTitleTest({ visits, input, expected }) { + await PlacesTestUtils.addVisits(visits); + for (const { uri, frecency } of visits) { + // Prepare data. + await PlacesUtils.withConnectionWrapper("test::doTitleTest", async db => { + await db.execute( + `UPDATE moz_places SET frecency = :frecency, recalc_frecency=0 WHERE url = :url`, + { + frecency, + url: uri, + } + ); + await db.executeCached("DELETE FROM moz_updateoriginsupdate_temp"); + }); + } + + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + matches: expected.matches(context), + }); + + await cleanup(); +} diff --git a/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js b/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js new file mode 100644 index 0000000000..9b305717c0 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js @@ -0,0 +1,2471 @@ +/* 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/. */ + +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const PLACES_PROVIDERNAME = "Places"; + +/** + * Helpful reminder of the `autofilled` and `completed` properties in the + * object passed to check_results: + * autofilled: expected input.value after autofill + * completed: expected input.value after autofill and enter is pressed + * + * `completed` is the URL that the controller sets to input.value, and the URL + * that will ultimately be loaded when you press enter. + */ + +async function cleanup() { + let suggestPrefs = ["history", "bookmark", "openpage"]; + for (let type of suggestPrefs) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + } + await cleanupPlaces(); +} + +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +let path; +let search; +let searchCase; +let visitTitle; +let url; +const host = "example.com"; +let origins; + +function add_autofill_task(callback) { + let func = async () => { + info(`Running subtest with origins disabled: ${callback.name}`); + origins = false; + path = "/foo"; + search = "example.com/f"; + searchCase = "EXAMPLE.COM/f"; + visitTitle = (protocol, sub) => + `test visit for ${protocol}://${sub}example.com/foo`; + url = host + path; + await callback(); + + info(`Running subtest with origins enabled: ${callback.name}`); + origins = true; + path = "/"; + search = "ex"; + searchCase = "EX"; + visitTitle = (protocol, sub) => + `test visit for ${protocol}://${sub}example.com/`; + url = host + path; + await callback(); + }; + Object.defineProperty(func, "name", { value: callback.name }); + add_task(func); +} + +// "ex" should match http://example.com/. +add_autofill_task(async function basic() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EX" should match http://example.com/. +add_autofill_task(async function basicCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: searchCase + url.substr(searchCase.length), + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match http://www.example.com/. +add_autofill_task(async function noWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "EX" should match http://www.example.com/. +add_autofill_task(async function noWWWShouldMatchWWWCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext(searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: searchCase + url.substr(searchCase.length), + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "www.ex" should *not* match http://example.com/. +add_autofill_task(async function wwwShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("www." + search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search + "/", + fallbackTitle: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + fallbackTitle: "http://www." + search, + iconUri: `page-icon:http://www.${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// "http://ex" should match http://example.com/. +add_autofill_task(async function prefix() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "HTTP://EX" should match http://example.com/. +add_autofill_task(async function prefixCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("HTTP://" + searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: "HTTP://" + searchCase + url.substr(searchCase.length), + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "http://ex" should match http://www.example.com/. +add_autofill_task(async function prefixNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "HTTP://EX" should match http://www.example.com/. +add_autofill_task(async function prefixNoWWWShouldMatchWWWCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://www." + url, + }, + ]); + let context = createContext("HTTP://" + searchCase, { isPrivate: false }); + await check_results({ + context, + autofilled: "HTTP://" + searchCase + url.substr(searchCase.length), + completed: "http://www." + url, + matches: [ + makeVisitResult(context, { + uri: "http://www." + url, + title: visitTitle("http", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "http://www.ex" should *not* match http://example.com/. +add_autofill_task(async function prefixWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("http://www." + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://www.${search}/` : `http://www.${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://www.${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "http://ex" should *not* match https://example.com/. +add_autofill_task(async function httpPrefixShouldNotMatchHTTPS() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match https://example.com/. +add_autofill_task(async function httpsBasic() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "ex" should match https://www.example.com/. +add_autofill_task(async function httpsNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://www." + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "www.ex" should *not* match https://example.com/. +add_autofill_task(async function httpsWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("www." + search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search + "/", + fallbackTitle: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + fallbackTitle: "http://www." + search, + iconUri: `page-icon:http://www.${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// "https://ex" should match https://example.com/. +add_autofill_task(async function httpsPrefix() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should match https://www.example.com/. +add_autofill_task(async function httpsPrefixNoWWWShouldMatchWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://www." + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// "https://www.ex" should *not* match https://example.com/. +add_autofill_task(async function httpsPrefixWWWShouldNotMatchNoWWW() { + await PlacesTestUtils.addVisits([ + { + uri: "https://" + url, + }, + ]); + let context = createContext("https://www." + search, { isPrivate: false }); + let prefixedUrl = origins + ? `https://www.${search}/` + : `https://www.${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:https://www.${host}/`, + providerame: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should *not* match http://example.com/. +add_autofill_task(async function httpsPrefixShouldNotMatchHTTP() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `https://${search}/` : `https://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:https://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// "https://ex" should *not* match http://example.com/, even if the latter is +// more frecent and both could be autofilled. +add_autofill_task(async function httpsPrefixShouldNotMatchMoreFrecentHTTP() { + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "http://" + url, + }, + { + uri: "https://" + url, + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "http://otherpage", + }, + ]); + let context = createContext("https://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "https://" + url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Autofill should respond to frecency changes. +add_autofill_task(async function frecency() { + // Start with an http visit. It should be completed. + await PlacesTestUtils.addVisits([ + { + uri: "http://" + url, + }, + ]); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + + // Add two https visits. https should now be completed. + for (let i = 0; i < 2; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://" + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Add two more http visits, three total. http should now be completed + // again. + for (let i = 0; i < 2; i++) { + await PlacesTestUtils.addVisits([{ uri: "http://" + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Add four www https visits. www https should now be completed. + for (let i = 0; i < 4; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://www." + url }]); + } + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://www." + url, + matches: [ + makeVisitResult(context, { + uri: "https://www." + url, + title: visitTitle("https", "www."), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Remove the www https page. + await PlacesUtils.history.remove(["https://www." + url]); + + // http should now be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Remove the http page. + await PlacesUtils.history.remove(["http://" + url]); + + // https should now be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Add a visit with a different host so that "ex" doesn't autofill it. + // https://example.com/ should still have a higher frecency though, so it + // should still be autofilled. + await PlacesTestUtils.addVisits([{ uri: "https://not-" + url }]); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + // Now add 10 more visits to the different host so that the frecency of + // https://example.com/ falls below the autofill threshold. It should not + // be autofilled now. + for (let i = 0; i < 10; i++) { + await PlacesTestUtils.addVisits([{ uri: "https://not-" + url }]); + } + + // In the `origins` case, the failure to make an autofill match means + // HeuristicFallback should not create a heuristic result. In the + // `!origins` case, autofill should still happen since there's no threshold + // comparison. + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + } + + // Remove the visits to the different host. + await PlacesUtils.history.remove(["https://not-" + url]); + + // https should be completed again. + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: visitTitle("https", ""), + heuristic: true, + }), + ], + }); + + // Remove the https visits. + await PlacesUtils.history.remove(["https://" + url]); + + // Now nothing should be completed. + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + + await cleanup(); +}); + +// Bookmarked places should always be autofilled, even when they don't meet +// the threshold. +add_autofill_task(async function bookmarkBelowThreshold() { + // Add some visits to a URL so that the origin autofill threshold is large. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://not-" + url, + }, + ]); + } + + // Now bookmark another URL. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make sure the bookmarked origin and place frecencies are below the + // threshold so that the origin/URL otherwise would not be autofilled. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://" + url } + ); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok( + placeFrecency < threshold, + `Place frecency should be below the threshold: ` + + `placeFrecency=${placeFrecency} threshold=${threshold}` + ); + Assert.ok( + originFrecency < threshold, + `Origin frecency should be below the threshold: ` + + `originFrecency=${originFrecency} threshold=${threshold}` + ); + + // The bookmark should be autofilled. + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://not-" + url, + title: "test visit for http://not-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + + await cleanup(); +}); + +// Bookmarked places should be autofilled when they *do* meet the threshold. +add_autofill_task(async function bookmarkAboveThreshold() { + // Bookmark a URL. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // The frecencies of the place and origin should be >= the threshold. In + // fact they should be the same as the threshold since the place is the only + // place in the database. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: "http://" + url } + ); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.equal(placeFrecency, threshold); + Assert.equal(originFrecency, threshold); + + // The bookmark should be autofilled. + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// Bookmark a page and then clear history. +// The bookmarked origin/URL should still be autofilled. +add_autofill_task(async function zeroThreshold() { + const pageUrl = "http://" + url; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: pageUrl, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await PlacesUtils.history.clear(); + await PlacesUtils.withConnectionWrapper("zeroThreshold", async db => { + await db.execute("UPDATE moz_places SET frecency = -1 WHERE url = :url", { + url: pageUrl, + }); + await db.executeCached("DELETE FROM moz_updateoriginsupdate_temp"); + }); + + // Make sure the place's frecency is -1. + let placeFrecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: pageUrl } + ); + Assert.equal(placeFrecency, -1); + + // Make sure the origin's frecency is 0. + let originFrecency = await getOriginFrecency("http://", host); + Assert.equal(originFrecency, 0); + + // Make sure the autofill threshold is 0. + let threshold = await getOriginAutofillThreshold(); + Assert.equal(threshold, 0); + + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_visit() { + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_visit_prefix() { + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestHistoryFalse_bookmark_0() { + // Add the bookmark. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make the bookmark fall below the autofill frecency threshold so we ensure + // the bookmark is always autofilled in this case, even if it doesn't meet + // the threshold. + let meetsThreshold = true; + while (meetsThreshold) { + // Add a visit to another origin to boost the threshold. + await PlacesTestUtils.addVisits("http://foo-" + url); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + meetsThreshold = threshold <= originFrecency; + } + + // At this point, the bookmark doesn't meet the threshold, but it should + // still be autofilled. + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok(originFrecency < threshold); + + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let context = createContext(search, { isPrivate: false }); + let matches = [ + makeBookmarkResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ]; + if (origins) { + matches.unshift( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } + await check_results({ + context, + matches, + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_0() { + // Add the bookmark. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Make the bookmark fall below the autofill frecency threshold so we ensure + // the bookmark is always autofilled in this case, even if it doesn't meet + // the threshold. + let meetsThreshold = true; + while (meetsThreshold) { + // Add a visit to another origin to boost the threshold. + await PlacesTestUtils.addVisits("http://foo-" + url); + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + meetsThreshold = threshold <= originFrecency; + } + + // At this point, the bookmark doesn't meet the threshold, but it should + // still be autofilled. + let originFrecency = await getOriginFrecency("http://", host); + let threshold = await getOriginAutofillThreshold(); + Assert.ok(originFrecency < threshold); + + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "ftp://" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_2() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = false +// suggest.bookmark = true +// search for: bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestHistoryFalse_bookmark_prefix_3() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + uri: "ftp://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visit_0() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: no +// prefix matches search: n/a +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://non-matching-" + url); + let context = createContext(search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + let matches = [ + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ]; + if (origins) { + matches.unshift( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } + await check_results({ + context, + matches, + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_0() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_1() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("ftp://" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "test visit for ftp://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_2() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("http://non-matching-" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visit +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visit_prefix_3() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "test visit for ftp://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_unvisitedBookmark() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(search, { isPrivate: false }); + if (origins) { + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + fallbackTitle: "http://" + search, + iconUri: `page-icon:http://${host}/`, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_0() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + heuristic: true, + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_1() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_2() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: unvisited bookmark +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_unvisitedBookmark_prefix_3() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task(async function suggestBookmarkFalse_visitedBookmark_above() { + await PlacesTestUtils.addVisits("http://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: yes +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_0() { + await PlacesTestUtils.addVisits("http://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_1() { + await PlacesTestUtils.addVisits("ftp://" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "ftp://" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_2() { + await PlacesTestUtils.addVisits("http://non-matching-" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark above autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkAbove_prefix_3() { + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + let context = createContext("http://" + search, { isPrivate: false }); + let prefixedUrl = origins ? `http://${search}/` : `http://${search}`; + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + fallbackTitle: prefixedUrl, + heuristic: true, + iconUri: origins ? "" : `page-icon:http://${host}/`, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeBookmarkResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "ftp://non-matching-" + url, + title: "A bookmark", + }), + ], + }); + await cleanup(); + } +); + +// The following suggestBookmarkFalse_visitedBookmarkBelow* tests are similar +// to the suggestBookmarkFalse_visitedBookmarkAbove* tests, but instead of +// checking visited bookmarks above the autofill threshold, they check visited +// bookmarks below the threshold. These tests don't make sense for URL +// queries (as opposed to origin queries) because URL queries don't use the +// same autofill threshold, so we skip them when !origins. + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: no +// prefix matches search: n/a +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task(async function suggestBookmarkFalse_visitedBookmarkBelow() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); +}); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_0() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: yes +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_1() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("ftp://" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("ftp://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "test visit for ftp://" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: yes +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_2() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("http://non-matching-" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("http://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// Tests interaction between the suggest.history and suggest.bookmark prefs. +// +// Config: +// suggest.history = true +// suggest.bookmark = false +// search for: visited bookmark below autofill threshold +// prefix search: yes +// prefix matches search: no +// origin matches search: no +// +// Expected result: +// should autofill: no +add_autofill_task( + async function suggestBookmarkFalse_visitedBookmarkBelow_prefix_3() { + if (!origins) { + // See comment above suggestBookmarkFalse_visitedBookmarkBelow. + return; + } + // First, make sure that `url` is below the autofill threshold. + await PlacesTestUtils.addVisits("ftp://non-matching-" + url); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits("ftp://some-other-" + url); + } + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "test visit for ftp://non-matching-" + url, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${search}/`, + fallbackTitle: `http://${search}/`, + heuristic: true, + iconUri: "", + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://some-other-" + url, + title: "test visit for ftp://some-other-" + url, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "A bookmark", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); + +// When the heuristic is hidden, "ex" should autofill http://example.com/, and +// there should be an additional http://example.com/ non-autofill result. +add_autofill_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + await PlacesTestUtils.addVisits("http://" + url); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title: visitTitle("http", ""), + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + }), + ], + }); + await cleanup(); + UrlbarPrefs.set("experimental.hideHeuristic", false); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js b/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js new file mode 100644 index 0000000000..872f891f63 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_origins_alt_frecency.js @@ -0,0 +1,243 @@ +/* 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/. */ + +// This is a basic autofill test to ensure enabling the alternative frecency +// algorithm doesn't break autofill or tab-to-search. A more comprehensive +// testing of the algorithm itself is not included since it's something that +// may change frequently according to experimentation results. +// Other existing autofill tests will, of course, need to be adapted once an +// algorithm is promoted to be the default. + +XPCOMUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => { + return Cc["@mozilla.org/places/frecency-recalculator;1"].getService( + Ci.nsIObserver + ).wrappedJSObject; +}); + +testEngine_setup(); + +add_task( + { + pref_set: [["browser.urlbar.suggest.quickactions", false]], + }, + async function test_autofill() { + const origin = "example.com"; + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "Suggestions", + heuristic: true, + }), + ], + }); + // Add many visits. + const url = `https://${origin}/`; + await PlacesTestUtils.addVisits(new Array(10).fill(url)); + Assert.equal( + await PlacesUtils.metadata.get("origin_alt_frecency_threshold", 0), + 0, + "Check there's no threshold initially" + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + Assert.greater( + await PlacesUtils.metadata.get("origin_alt_frecency_threshold", 0), + 0, + "Check a threshold has been calculated" + ); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); + } +); + +add_task( + { + pref_set: [["browser.urlbar.suggest.quickactions", false]], + }, + async function test_autofill_www() { + const origin = "example.com"; + // Add many visits. + const url = `https://www.${origin}/`; + await PlacesTestUtils.addVisits(new Array(10).fill(url)); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); + } +); + +add_task( + { + pref_set: [ + ["browser.urlbar.suggest.quickactions", false], + ["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0], + ], + }, + async function test_autofill_prefix_priority() { + const origin = "localhost"; + const url = `https://${origin}/`; + await PlacesTestUtils.addVisits([url, `http://${origin}/`]); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let engine = Services.search.defaultEngine; + let context = createContext(origin.substring(0, 2), { isPrivate: false }); + await check_results({ + context, + autofilled: `${origin}/`, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await PlacesUtils.history.clear(); + } +); + +add_task( + { + pref_set: [["browser.urlbar.suggest.quickactions", false]], + }, + async function test_autofill_threshold() { + async function getOriginAltFrecency(origin) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT alt_frecency FROM moz_origins WHERE host = :origin", + { origin } + ); + return rows?.[0].getResultByName("alt_frecency"); + } + async function getThreshold() { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute("SELECT avg(alt_frecency) FROM moz_origins"); + return rows[0].getResultByIndex(0); + } + + await PlacesTestUtils.addVisits(new Array(10).fill("https://example.com/")); + // Add more visits to the same origins to differenciate the frecency scores. + await PlacesTestUtils.addVisits([ + "https://example.com/2", + "https://example.com/3", + ]); + await PlacesTestUtils.addVisits("https://somethingelse.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + let threshold = await PlacesUtils.metadata.get( + "origin_alt_frecency_threshold", + 0 + ); + Assert.greater( + threshold, + await getOriginAltFrecency("somethingelse.org"), + "Check mozilla.org has a lower frecency than the threshold" + ); + Assert.equal( + threshold, + await getThreshold(), + "Check the threshold has been calculared correctly" + ); + + let engine = Services.search.defaultEngine; + let context = createContext("so", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "so", + engineName: engine.name, + }), + makeVisitResult(context, { + uri: "https://somethingelse.org/", + title: "test visit for https://somethingelse.org/", + }), + ], + }); + await PlacesUtils.history.clear(); + } +); + +add_task( + { + pref_set: [["browser.urlbar.suggest.quickactions", false]], + }, + async function test_autofill_cutoff() { + async function getOriginAltFrecency(origin) { + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT alt_frecency FROM moz_origins WHERE host = :origin", + { origin } + ); + return rows?.[0].getResultByName("alt_frecency"); + } + + // Add many visits older than the default 90 days cutoff. + const visitDate = new Date(Date.now() - 120 * 86400000); + await PlacesTestUtils.addVisits( + new Array(10) + .fill("https://example.com/") + .map(url => ({ url, visitDate })) + ); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + Assert.strictEqual( + await getOriginAltFrecency("example.com"), + null, + "Check example.com has a NULL frecency" + ); + + let engine = Services.search.defaultEngine; + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: "ex", + engineName: engine.name, + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + }), + ], + }); + await PlacesUtils.history.clear(); + } +); diff --git a/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js b/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js new file mode 100644 index 0000000000..dfb5de2149 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.js @@ -0,0 +1,76 @@ +/* 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/. */ + +// This tests autofill prefix fallback in case multiple origins have the same +// exact frecency. +// We should prefer https, or in case of other prefixes just sort by descending +// id. + +add_task(async function () { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + let host = "example.com"; + let prefixes = ["https://", "https://www.", "http://", "http://www."]; + for (let prefix of prefixes) { + await PlacesUtils.bookmarks.insert({ + url: `${prefix}${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + await checkOriginsOrder(host, prefixes); + + // The https://www version should be filled because it's https and the www + // version has been added later so it has an higher id. + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://www.${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://www.${host}/`, + fallbackTitle: `https://www.${host}`, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: `https://${host}/`, + title: `${host}`, + }), + ], + }); + + // Remove and reinsert bookmarks in another order. + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + prefixes = ["https://www.", "http://", "https://", "http://www."]; + for (let prefix of prefixes) { + await PlacesUtils.bookmarks.insert({ + url: `${prefix}${host}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + } + await checkOriginsOrder(host, prefixes); + + await check_results({ + context, + autofilled: `${host}/`, + completed: `https://${host}/`, + matches: [ + makeVisitResult(context, { + uri: `https://${host}/`, + fallbackTitle: `https://${host}`, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: `https://www.${host}/`, + title: `www.${host}`, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js b/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js new file mode 100644 index 0000000000..018a2e1681 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js @@ -0,0 +1,85 @@ +/* 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/. */ + +// Tests autofilling search engine token ("@") aliases. + +"use strict"; + +const TEST_ENGINE_NAME = "test autofill aliases"; +const TEST_ENGINE_ALIAS = "@autofilltest"; + +add_task(async function init() { + // Add an engine with an "@" alias. + await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE_NAME, + keyword: TEST_ENGINE_ALIAS, + }); +}); + +// Searching for @autofi should autofill to @autofilltest. +add_task(async function basic() { + // Add a history visit that should normally match but for the fact that the + // search uses an @ alias. When an @ alias is autofilled, there should be no + // other matches except the autofill heuristic match. + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + title: TEST_ENGINE_ALIAS, + }); + + let search = TEST_ENGINE_ALIAS.substr( + 0, + Math.round(TEST_ENGINE_ALIAS.length / 2) + ); + let autofilledValue = TEST_ENGINE_ALIAS + " "; + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: autofilledValue, + matches: [ + makeSearchResult(context, { + engineName: TEST_ENGINE_NAME, + alias: TEST_ENGINE_ALIAS, + query: "", + providesSearchMode: true, + heuristic: false, + }), + ], + }); + await cleanupPlaces(); +}); + +// Searching for @AUTOFI should autofill to @AUTOFIlltest, preserving the case +// in the search string. +add_task(async function preserveCase() { + // Add a history visit that should normally match but for the fact that the + // search uses an @ alias. When an @ alias is autofilled, there should be no + // other matches except the autofill heuristic match. + await PlacesTestUtils.addVisits({ + uri: "http://example.com/", + title: TEST_ENGINE_ALIAS, + }); + + let search = TEST_ENGINE_ALIAS.toUpperCase().substr( + 0, + Math.round(TEST_ENGINE_ALIAS.length / 2) + ); + let alias = search + TEST_ENGINE_ALIAS.substr(search.length); + + let autofilledValue = alias + " "; + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: autofilledValue, + matches: [ + makeSearchResult(context, { + engineName: TEST_ENGINE_NAME, + alias, + query: "", + providesSearchMode: true, + heuristic: false, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_search_engines.js b/browser/components/urlbar/tests/unit/test_autofill_search_engines.js new file mode 100644 index 0000000000..7b836a7c4d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_search_engines.js @@ -0,0 +1,246 @@ +/* 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/. */ + +// The autoFill.searchEngines pref autofills the domains of engines registered +// with the search service. That's what this test checks. It's a different +// path in ProviderAutofill from normal moz_places autofill, which is tested +// in test_autofill_origins.js and test_autofill_urls.js. + +"use strict"; + +const ENGINE_NAME = "engine.xml"; + +add_task(async function searchEngines() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + }); + + // Bug 1149672: Once we drop support for http with OpenSearch engines, + // we should be able to drop the http part of this. + for (let scheme of ["https", "http"]) { + let extension; + if (scheme == "https") { + extension = await SearchTestUtils.installSearchExtension( + { + name: ENGINE_NAME, + search_url: "https://www.example.com/", + }, + { skipUnload: true } + ); + } else { + let httpServer = makeTestServer(); + httpServer.registerDirectory("/", do_get_cwd()); + await Services.search.addOpenSearchEngine( + `http://localhost:${httpServer.identity.primaryPort}/data/engine.xml`, + null + ); + } + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("example.com", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("example.com/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("www.ex", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("www.example.com", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("www.example.com/", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://ex", { isPrivate: false }); + await check_results({ + context, + autofilled: scheme + "://example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://example.com", { isPrivate: false }); + await check_results({ + context, + autofilled: scheme + "://example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://example.com/", { isPrivate: false }); + await check_results({ + context, + autofilled: scheme + "://example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://www.ex", { isPrivate: false }); + await check_results({ + context, + autofilled: scheme + "://www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://www.example.com", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: scheme + "://www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext(scheme + "://www.example.com/", { + isPrivate: false, + }); + await check_results({ + context, + search: scheme + "://www.example.com/", + autofilled: scheme + "://www.example.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + // We should just get a normal heuristic result from HeuristicFallback for + // these queries. + let otherScheme = scheme == "http" ? "https" : "http"; + context = createContext(otherScheme + "://ex", { isPrivate: false }); + await check_results({ + context, + search: otherScheme + "://ex", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: otherScheme + "://ex/", + fallbackTitle: otherScheme + "://ex/", + heuristic: true, + }), + ], + }); + context = createContext(otherScheme + "://www.ex", { isPrivate: false }); + await check_results({ + context, + search: otherScheme + "://www.ex", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: otherScheme + "://www.ex/", + fallbackTitle: otherScheme + "://www.ex/", + heuristic: true, + }), + ], + }); + + context = createContext("example/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example/", + fallbackTitle: "http://example/", + iconUri: "page-icon:http://example/", + heuristic: true, + }), + ], + }); + + await extension?.unload(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_autofill_urls.js b/browser/components/urlbar/tests/unit/test_autofill_urls.js new file mode 100644 index 0000000000..c816736531 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_urls.js @@ -0,0 +1,881 @@ +/* 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/. */ + +"use strict"; + +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const PLACES_PROVIDERNAME = "Places"; + +// "example.com/foo/" should match http://example.com/foo/. +testEngine_setup(); + +registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); +}); +Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + +add_task(async function multipleSlashes() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + }, + ]); + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/foo/", + title: "test visit for http://example.com/foo/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// "example.com:8888/f" should match http://example.com:8888/foo. +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo", + }, + ]); + let context = createContext("example.com:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/foo", + completed: "http://example.com:8888/foo", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo", + title: "test visit for http://example.com:8888/foo", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// "example.com:8999/f" should *not* autofill http://example.com:8888/foo. +add_task(async function portNoMatch() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo", + }, + ]); + let context = createContext("example.com:8999/f", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example.com:8999/f", + fallbackTitle: "http://example.com:8999/f", + iconUri: "page-icon:http://example.com:8999/", + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill to the next slash +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo/bar/baz", + }, + ]); + let context = createContext("example.com:8888/foo/b", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com:8888/foo/bar/", + completed: "http://example.com:8888/foo/bar/", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/", + fallbackTitle: "example.com:8888/foo/bar/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/baz", + title: "test visit for http://example.com:8888/foo/bar/baz", + tags: [], + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill to the next slash, end of url +add_task(async function port() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com:8888/foo/bar/baz", + }, + ]); + let context = createContext("example.com:8888/foo/bar/b", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "example.com:8888/foo/bar/baz", + completed: "http://example.com:8888/foo/bar/baz", + matches: [ + makeVisitResult(context, { + uri: "http://example.com:8888/foo/bar/baz", + title: "test visit for http://example.com:8888/foo/bar/baz", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// autofill with case insensitive from history and bookmark. +add_task(async function caseInsensitiveFromHistoryAndBookmark() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo", + }, + ]); + + await testCaseInsensitive(); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// autofill with case insensitive from history. +add_task(async function caseInsensitiveFromHistory() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo", + }, + ]); + + await testCaseInsensitive(); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// autofill with case insensitive from bookmark. +add_task(async function caseInsensitiveFromBookmark() { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://example.com/foo", + }); + + await testCaseInsensitive(true); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +// should *not* autofill if the URI fragment does not match with case-sensitive. +add_task(async function uriFragmentCaseSensitive() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/#TEST", + }, + ]); + const context = createContext("http://example.com/#t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://example.com/#t", + fallbackTitle: "http://example.com/#t", + heuristic: true, + }), + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://example.com/#TEST", + title: "test visit for http://example.com/#TEST", + tags: [], + }), + ], + }); + + await cleanupPlaces(); +}); + +// should autofill if the URI fragment matches with case-sensitive. +add_task(async function uriFragmentCaseSensitive() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/#TEST", + }, + ]); + const context = createContext("http://example.com/#T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://example.com/#TEST", + completed: "http://example.com/#TEST", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://example.com/#TEST", + title: "test visit for http://example.com/#TEST", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function uriCase() { + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/ABC/DEF", + }, + ]); + + const testData = [ + { + input: "example.COM", + expected: { + autofilled: "example.COM/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.COM/", + expected: { + autofilled: "example.COM/", + completed: "http://example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/", + fallbackTitle: "example.com/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.COM/a", + expected: { + autofilled: "example.COM/aBC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: "example.com/ABC/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/ab", + expected: { + autofilled: "example.com/abC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: "example.com/ABC/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc", + expected: { + autofilled: "example.com/abc/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: "example.com/ABC/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc/", + expected: { + autofilled: "example.com/abc/", + completed: "http://example.com/abc/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/", + fallbackTitle: "example.com/abc/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "example.com/abc/d", + expected: { + autofilled: "example.com/abc/dEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "example.com/abc/de", + expected: { + autofilled: "example.com/abc/deF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "example.com/abc/def", + expected: { + autofilled: "example.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: "example.com/abc/def", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/a", + expected: { + autofilled: "http://example.com/aBC/", + completed: "http://example.com/ABC/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/", + fallbackTitle: "example.com/ABC/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/abc/", + expected: { + autofilled: "http://example.com/abc/", + completed: "http://example.com/abc/", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/", + fallbackTitle: "example.com/abc/", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://example.com/abc/d", + expected: { + autofilled: "http://example.com/abc/dEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "http://example.com/abc/def", + expected: { + autofilled: "http://example.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: "example.com/abc/def", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + { + input: "http://eXAMple.com/ABC/DEF", + expected: { + autofilled: "http://eXAMple.com/ABC/DEF", + completed: "http://example.com/ABC/DEF", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + heuristic: true, + }), + ], + }, + }, + { + input: "http://eXAMple.com/abc/def", + expected: { + autofilled: "http://eXAMple.com/abc/def", + completed: "http://example.com/abc/def", + results: [ + context => + makeVisitResult(context, { + uri: "http://example.com/abc/def", + fallbackTitle: "example.com/abc/def", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "http://example.com/ABC/DEF", + title: "test visit for http://example.com/ABC/DEF", + }), + ], + }, + }, + ]; + + for (const { input, expected } of testData) { + const context = createContext(input, { + isPrivate: false, + }); + await check_results({ + context, + autofilled: expected.autofilled, + completed: expected.completed, + matches: expected.results.map(f => f(context)), + }); + } + + await cleanupPlaces(); +}); + +async function testCaseInsensitive(isBookmark = false) { + const testData = [ + { + input: "example.com/F", + expectedAutofill: "example.com/Foo", + }, + { + // Test with prefix. + input: "http://example.com/F", + expectedAutofill: "http://example.com/Foo", + }, + ]; + + for (const { input, expectedAutofill } of testData) { + const context = createContext(input, { + isPrivate: false, + }); + await check_results({ + context, + autofilled: expectedAutofill, + completed: "http://example.com/foo", + matches: [ + makeVisitResult(context, { + uri: "http://example.com/foo", + title: isBookmark + ? "A bookmark" + : "test visit for http://example.com/foo", + heuristic: true, + }), + ], + }); + } + + await cleanupPlaces(); +} + +// Checks a URL with an origin that looks like a prefix: a scheme with no dots + +// a port. +add_task(async function originLooksLikePrefix1() { + await PlacesTestUtils.addVisits([ + { + uri: "http://localhost:8888/foo", + }, + ]); + const context = createContext("localhost:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo", + completed: "http://localhost:8888/foo", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo", + title: "test visit for http://localhost:8888/foo", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// Same as previous (originLooksLikePrefix1) but uses a URL whose path has two +// slashes, not one. +add_task(async function originLooksLikePrefix2() { + await PlacesTestUtils.addVisits([ + { + uri: "http://localhost:8888/foo/bar", + }, + ]); + + let context = createContext("localhost:8888/f", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo/", + completed: "http://localhost:8888/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo/", + fallbackTitle: "localhost:8888/foo/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://localhost:8888/foo/bar", + title: "test visit for http://localhost:8888/foo/bar", + providerName: PLACES_PROVIDERNAME, + tags: [], + }), + ], + }); + + context = createContext("localhost:8888/foo/b", { isPrivate: false }); + await check_results({ + context, + autofilled: "localhost:8888/foo/bar", + completed: "http://localhost:8888/foo/bar", + matches: [ + makeVisitResult(context, { + uri: "http://localhost:8888/foo/bar", + title: "test visit for http://localhost:8888/foo/bar", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +// Checks view-source pages as a prefix +// Uses bookmark because addVisits does not allow non-http uri's +add_task(async function viewSourceAsPrefix() { + let address = "view-source:https://www.example.com/"; + let title = "A view source bookmark"; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: address, + title, + }); + + let testData = [ + { + input: "view-source:h", + completed: "view-source:https:/", + autofilled: "view-source:https:/", + }, + { + input: "view-source:http", + completed: "view-source:https:/", + autofilled: "view-source:https:/", + }, + { + input: "VIEW-SOURCE:http", + completed: "view-source:https:/", + autofilled: "VIEW-SOURCE:https:/", + }, + ]; + + // Only autofills from view-source:h to view-source:https:/ + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + makeBookmarkResult(context, { + uri: address, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title, + }), + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks data url prefixes +// Uses bookmark because addVisits does not allow non-http uri's +add_task(async function dataAsPrefix() { + let address = "data:text/html,%3Ch1%3EHello%2C World!%3C%2Fh1%3E"; + let title = "A data url bookmark"; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: address, + title, + }); + + let testData = [ + { + input: "data:t", + completed: "data:text/", + autofilled: "data:text/", + }, + { + input: "data:text", + completed: "data:text/", + autofilled: "data:text/", + }, + { + input: "DATA:text", + completed: "data:text/", + autofilled: "DATA:text/", + }, + ]; + + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + makeBookmarkResult(context, { + uri: address, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title, + }), + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks about prefixes +add_task(async function aboutAsPrefix() { + let testData = [ + { + input: "about:abou", + completed: "about:about", + autofilled: "about:about", + }, + { + input: "ABOUT:abou", + completed: "about:about", + autofilled: "ABOUT:about", + }, + ]; + + for (let { input, completed, autofilled } of testData) { + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed, + autofilled, + matches: [ + { + heuristic: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }, + ], + }); + } + + await cleanupPlaces(); +}); + +// Checks a URL that has www name in history. +add_task(async function wwwHistory() { + const testData = [ + { + input: "example.com/", + visitHistory: [{ uri: "http://www.example.com/", title: "Example" }], + expected: { + autofilled: "example.com/", + completed: "http://www.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "http://www.example.com/", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/", + visitHistory: [{ uri: "https://www.example.com/", title: "Example" }], + expected: { + autofilled: "https://example.com/", + completed: "https://www.example.com/", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/abc", + visitHistory: [{ uri: "https://www.example.com/abc", title: "Example" }], + expected: { + autofilled: "https://example.com/abc", + completed: "https://www.example.com/abc", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/abc", + title: "Example", + heuristic: true, + }), + ], + }, + }, + { + input: "https://example.com/ABC", + visitHistory: [{ uri: "https://www.example.com/abc", title: "Example" }], + expected: { + autofilled: "https://example.com/ABC", + completed: "https://www.example.com/ABC", + results: [ + context => + makeVisitResult(context, { + uri: "https://www.example.com/ABC", + fallbackTitle: "https://www.example.com/ABC", + heuristic: true, + }), + context => + makeVisitResult(context, { + uri: "https://www.example.com/abc", + title: "Example", + }), + ], + }, + }, + ]; + + for (const { input, visitHistory, expected } of testData) { + await PlacesTestUtils.addVisits(visitHistory); + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + completed: expected.completed, + autofilled: expected.autofilled, + matches: expected.results.map(f => f(context)), + }); + await cleanupPlaces(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_avoid_middle_complete.js b/browser/components/urlbar/tests/unit/test_avoid_middle_complete.js new file mode 100644 index 0000000000..5e2dba3446 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_avoid_middle_complete.js @@ -0,0 +1,270 @@ +/* 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/. */ + +testEngine_setup(); + +add_task(async function test_prefix_space_noautofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info("Should not try to autoFill if search string contains a space"); + let context = createContext(" mo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: " mo", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://moz.org/test/", + title: "test visit for http://moz.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_trailing_space_noautofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info("Should not try to autoFill if search string contains a space"); + let context = createContext("mo ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "mo ", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://moz.org/test/", + title: "test visit for http://moz.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_autofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "CakeSearch", + search_url: "https://cake.search", + }); + + info( + "Should autoFill search engine if search string does not contains a space" + ); + let context = createContext("ca", { isPrivate: false }); + await check_results({ + context, + matches: [ + makePrioritySearchResult(context, { + engineName: "CakeSearch", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_prefix_space_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "CupcakeSearch", + search_url: "https://cupcake.search", + }); + + info( + "Should not try to autoFill search engine if search string contains a space" + ); + let context = createContext(" cu", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: " cu", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_trailing_space_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "BaconSearch", + search_url: "https://bacon.search", + }); + + info( + "Should not try to autoFill search engine if search string contains a space" + ); + let context = createContext("ba ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "ba ", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_www_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "HamSearch", + search_url: "https://ham.search", + }); + + info( + "Should not autoFill search engine if search string contains www. but engine doesn't" + ); + let context = createContext("www.ham", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.ham/", + fallbackTitle: "http://www.ham/", + displayUrl: "http://www.ham", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "www.ham", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_different_scheme_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "PieSearch", + search_url: "https://pie.search", + }); + + info( + "Should not autoFill search engine if search string has a different scheme." + ); + let context = createContext("http://pie", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://pie/", + fallbackTitle: "http://pie/", + iconUri: "", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_matching_prefix_autofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + await SearchTestUtils.installSearchExtension({ + name: "BeanSearch", + search_url: "https://www.bean.search", + }); + + info("Should autoFill search engine if search string has matching prefix."); + let context = createContext("https://www.be", { isPrivate: false }); + await check_results({ + context, + autofilled: "https://www.bean.search/", + matches: [ + makePrioritySearchResult(context, { + engineName: "BeanSearch", + heuristic: true, + }), + ], + }); + + info("Should autoFill search engine if search string has www prefix."); + context = createContext("www.be", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.bean.search/", + matches: [ + makePrioritySearchResult(context, { + engineName: "BeanSearch", + heuristic: true, + }), + ], + }); + + info("Should autoFill search engine if search string has matching scheme."); + context = createContext("https://be", { isPrivate: false }); + await check_results({ + context, + autofilled: "https://bean.search/", + matches: [ + makePrioritySearchResult(context, { + engineName: "BeanSearch", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_prefix_autofill() { + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://moz.org/test/"), + }); + + info( + "Should not try to autoFill in-the-middle if a search is canceled immediately" + ); + let context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + incompleteSearch: "moz", + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: "Places", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js b/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js new file mode 100644 index 0000000000..b4aa7e50bd --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.js @@ -0,0 +1,119 @@ +/* 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/. */ + +testEngine_setup(); + +add_task(async function test_protocol_trimming() { + for (let prot of ["http", "https"]) { + let visit = { + // Include the protocol in the query string to ensure we get matches (see bug 1059395) + uri: Services.io.newURI( + prot + + "://www.mozilla.org/test/?q=" + + prot + + encodeURIComponent("://") + + "www.foo" + ), + title: "Test title", + }; + await PlacesTestUtils.addVisits(visit); + + let input = prot + "://www."; + info("Searching for: " + input); + let context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: prot + "://www.mozilla.org/", + completed: prot + "://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: prot + "://www.mozilla.org/", + fallbackTitle: + prot == "http" ? "www.mozilla.org" : prot + "://www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + }), + ], + }); + + input = "www."; + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + autofilled: "www.mozilla.org/", + completed: prot + "://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: prot + "://www.mozilla.org/", + fallbackTitle: + prot == "http" ? "www.mozilla.org" : prot + "://www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + }), + ], + }); + + input = prot + "://www. "; + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${input.trim()}/`, + fallbackTitle: `${input.trim()}/`, + iconUri: "", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "Places", + }), + ], + }); + + let inputs = [ + prot + "://", + prot + ":// ", + prot + ":// mo", + prot + "://mo te", + prot + "://www. mo", + prot + "://www.mo te", + "www. ", + "www. mo", + "www.mo te", + ]; + for (input of inputs) { + info("Searching for: " + input); + context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: input, + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "Places", + }), + ], + }); + } + + await cleanupPlaces(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_calculator.js b/browser/components/urlbar/tests/unit/test_calculator.js new file mode 100644 index 0000000000..7fa899f320 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_calculator.js @@ -0,0 +1,46 @@ +/* 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Calculator: "resource:///modules/UrlbarProviderCalculator.sys.mjs", +}); + +const FORMULAS = [ + ["1+1", 2], + ["3+4*2/(1-5)", 1], + ["39+4*2/(1-5)", 37], + ["(39+4)*2/(1-5)", -21.5], + ["4+-5", -1], + ["-5*6", -30], + ["-5.5*6", -33], + ["-5.5*-6.4", 35.2], + ["-6-6-6", -18], + ["6-6-6", -6], + [".001 /2", 0.0005], + ["(0-.001)/2", -0.0005], + ["-.001/(0-2)", 0.0005], + ["1000000000000000000000000+1", 1e24], + ["1000000000000000000000000-1", 1e24], + ["1e+30+10", 1e30], + ["1e+30*10", 1e31], + ["1e+30/100", 1e28], + ["10/1000000000000000000000000", 1e-23], + ["10/-1000000000000000000000000", -1e-23], + ["1,500.5+2.5", 1503], // Ignore commas when using decimal seperators + ["1,5+2,5", 4], // Support comma seperators + ["1.500,5+2,5", 1503], // Ignore periods when using comma decimal seperators +]; + +add_task(function test() { + for (let [formula, result] of FORMULAS) { + let postfix = Calculator.infix2postfix(formula); + Assert.equal( + Calculator.evaluatePostfix(postfix), + result, + `${formula} should equal ${result}` + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_casing.js b/browser/components/urlbar/tests/unit/test_casing.js new file mode 100644 index 0000000000..91d8207c05 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_casing.js @@ -0,0 +1,370 @@ +/* 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/. */ + +const AUTOFILL_PROVIDERNAME = "Autofill"; +const PLACES_PROVIDERNAME = "Places"; + +testEngine_setup(); + +add_task(async function test_casing_1() { + info("Searching for cased entry 1"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("MOZ", { isPrivate: false }); + await check_results({ + context, + autofilled: "MOZilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_2() { + info("Searching for cased entry 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("mozilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/Test/", + completed: "http://mozilla.org/test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + iconUri: "page-icon:http://mozilla.org/test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_3() { + info("Searching for cased entry 3"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mozilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_4() { + info("Searching for cased entry 4"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mOzilla.org/test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + iconUri: "page-icon:http://mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_casing_5() { + info("Searching for cased entry 5"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "mOzilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_casing() { + info("Searching for untrimmed cased entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOz", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www_casing() { + info("Searching for untrimmed cased entry with www"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOz", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/", + completed: "http://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/", + fallbackTitle: "www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_casing() { + info("Searching for untrimmed cased entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + iconUri: "page-icon:http://mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_casing_2() { + info("Searching for untrimmed cased entry with path 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/Test/"), + }); + let context = createContext("http://mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://mOzilla.org/Test/", + completed: "http://mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_www_casing() { + info("Searching for untrimmed cased entry with www and path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOzilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/test/", + completed: "http://www.mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + iconUri: "page-icon:http://www.mozilla.org/Test/", + heuristic: true, + providerName: AUTOFILL_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_path_www_casing_2() { + info("Searching for untrimmed cased entry with www and path 2"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/Test/"), + }); + let context = createContext("http://www.mOzilla.org/T", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.mOzilla.org/Test/", + completed: "http://www.mozilla.org/Test/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_searching() { + let uri1 = Services.io.newURI("http://dummy/1/"); + let uri2 = Services.io.newURI("http://dummy/2/"); + let uri3 = Services.io.newURI("http://dummy/3/"); + let uri4 = Services.io.newURI("http://dummy/4/"); + let uri5 = Services.io.newURI("http://dummy/5/"); + + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "uppercase lambda \u039B" }, + { uri: uri2, title: "lowercase lambda \u03BB" }, + { uri: uri3, title: "symbol \u212A" }, // kelvin + { uri: uri4, title: "uppercase K" }, + { uri: uri5, title: "lowercase k" }, + ]); + + info("Search for lowercase lambda"); + let context = createContext("\u03BB", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "lowercase lambda \u03BB", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "uppercase lambda \u039B", + }), + ], + }); + + info("Search for uppercase lambda"); + context = createContext("\u039B", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "lowercase lambda \u03BB", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "uppercase lambda \u039B", + }), + ], + }); + + info("Search for kelvin sign"); + context = createContext("\u212A", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + info("Search for lowercase k"); + context = createContext("k", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + info("Search for uppercase k"); + + context = createContext("K", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "lowercase k" }), + makeVisitResult(context, { uri: uri4.spec, title: "uppercase K" }), + makeVisitResult(context, { uri: uri3.spec, title: "symbol \u212A" }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_dedupe_prefix.js b/browser/components/urlbar/tests/unit/test_dedupe_prefix.js new file mode 100644 index 0000000000..47a673d064 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_prefix.js @@ -0,0 +1,277 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing that we dedupe results that have the same URL and title as another +// except for their prefix (e.g. http://www.). +add_task(async function dedupe_prefix() { + // We need to set the title or else we won't dedupe. We only dedupe when + // titles match up to mitigate deduping when the www. version of a site is + // completely different from it's www-less counterpart and thus presumably + // has a different title. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + title: "Example Page", + }, + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + // Note that we add https://www.example.com/foo/ twice here. + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + ]); + + // Expected results: + // + // Autofill result: + // https://www.example.com has the highest origin frecency since we added 2 + // visits to https://www.example.com/foo/ and only one visit to the other + // URLs. + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, and it + // does not dupe the autofill result, so only it should be included. + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add more visits to the lowest-priority prefix. It should be the heuristic + // result but we should still show our highest-priority result. https://www. + // should not appear at all. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // http://www.example.com now has the highest origin frecency since we added + // 4 visits to http://www.example.com/foo/ + // Other results: + // Same as before + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add enough https:// vists for it to have the highest frecency. It should + // be the heuristic result. We should still get the https://www. result + // because we still show results with the same key and protocol if they differ + // from the heuristic result in having www. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // https://example.com now has the highest origin frecency since we added + // 6 visits to https://example.com/foo/ + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, but it + // dupes the heuristic so it should not be included. + // https://www.example.com/foo/ has the next highest prefix rank, and it + // does not dupe the heuristic, so only it should be included. + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +// This is the same as the previous task but with `experimental.hideHeuristic` +// enabled. +add_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + + // We need to set the title or else we won't dedupe. We only dedupe when + // titles match up to mitigate deduping when the www. version of a site is + // completely different from it's www-less counterpart and thus presumably + // has a different title. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/foo/", + title: "Example Page", + }, + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + // Note that we add https://www.example.com/foo/ twice here. + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + ]); + + // Expected results: + // + // Autofill result: + // https://www.example.com has the highest origin frecency since we added 2 + // visits to https://www.example.com/foo/ and only one visit to the other + // URLs. + // Other results: + // https://example.com/foo/ has the highest possible prefix rank, and it + // does not dupe the autofill result, so only it should be included. + let context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add more visits to the lowest-priority prefix. It should be the heuristic + // result but we should still show our highest-priority result. https://www. + // should not appear at all. + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "http://www.example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // http://www.example.com now has the highest origin frecency since we added + // 4 visits to http://www.example.com/foo/ + // Other results: + // Same as before + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "http://www.example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "http://www.example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + // Add enough https:// vists for it to have the highest frecency. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: "https://example.com/foo/", + title: "Example Page", + }, + ]); + } + + // Expected results: + // + // Autofill result: + // https://example.com now has the highest origin frecency since we added + // 6 visits to https://example.com/foo/ + // Other results: + // https://example.com/foo/ has the highest possible prefix rank. It dupes + // the heuristic so ordinarily it should not be included, but because the + // heuristic is hidden, only it should appear. + context = createContext("example.com/foo/", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/foo/", + completed: "https://example.com/foo/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://example.com/foo/", + title: "Example Page", + }), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); diff --git a/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js b/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js new file mode 100644 index 0000000000..3b49866b1e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_switchTab.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +testEngine_setup(); + +add_task(async function test_deduplication_for_switch_tab() { + // Set up Places to think the tab is open locally. + let uri = Services.io.newURI("http://example.com/"); + + await PlacesTestUtils.addVisits({ uri, title: "An Example" }); + await addOpenPages(uri, 1); + await UrlbarUtils.addToInputHistory("http://example.com/", "An"); + + let query = "An"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://example.com/", + title: "An Example", + }), + ], + }); + + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js b/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js new file mode 100644 index 0000000000..29ce557748 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_download_embed_bookmarks.js @@ -0,0 +1,137 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +/** + * Tests bug 449406 to ensure that TRANSITION_DOWNLOAD, TRANSITION_EMBED and + * TRANSITION_FRAMED_LINK bookmarked uri's show up in the location bar. + */ + +testEngine_setup(); + +const TRANSITION_EMBED = PlacesUtils.history.TRANSITION_EMBED; +const TRANSITION_FRAMED_LINK = PlacesUtils.history.TRANSITION_FRAMED_LINK; +const TRANSITION_DOWNLOAD = PlacesUtils.history.TRANSITION_DOWNLOAD; + +add_task(async function test_download_embed_bookmarks() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://download/bookmarked"); + let uri2 = Services.io.newURI("http://embed/bookmarked"); + let uri3 = Services.io.newURI("http://framed/bookmarked"); + let uri4 = Services.io.newURI("http://download"); + let uri5 = Services.io.newURI("http://embed"); + let uri6 = Services.io.newURI("http://framed"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "download-bookmark", transition: TRANSITION_DOWNLOAD }, + { uri: uri2, title: "embed-bookmark", transition: TRANSITION_EMBED }, + { uri: uri3, title: "framed-bookmark", transition: TRANSITION_FRAMED_LINK }, + { uri: uri4, title: "download2", transition: TRANSITION_DOWNLOAD }, + { uri: uri5, title: "embed2", transition: TRANSITION_EMBED }, + { uri: uri6, title: "framed2", transition: TRANSITION_FRAMED_LINK }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "download-bookmark", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "embed-bookmark", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "framed-bookmark", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Searching for bookmarked download uri matches"); + let context = createContext("download-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "download-bookmark", + }), + ], + }); + + info("Searching for bookmarked embed uri matches"); + context = createContext("embed-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "embed-bookmark", + }), + ], + }); + + info("Searching for bookmarked framed uri matches"); + context = createContext("framed-bookmark", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "framed-bookmark", + }), + ], + }); + + info("Searching for download uri does not match"); + context = createContext("download2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Searching for embed uri does not match"); + context = createContext("embed2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Searching for framed uri does not match"); + context = createContext("framed2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_empty_search.js b/browser/components/urlbar/tests/unit/test_empty_search.js new file mode 100644 index 0000000000..2c6dffe8e6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_empty_search.js @@ -0,0 +1,181 @@ +/* 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 bug 426864 that makes sure searching a space only shows typed pages + * from history. + */ + +testEngine_setup(); + +add_task(async function test_empty_search() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + let uri1 = Services.io.newURI("http://t.foo/1"); + let uri2 = Services.io.newURI("http://t.foo/2"); + let uri3 = Services.io.newURI("http://t.foo/3"); + let uri4 = Services.io.newURI("http://t.foo/4"); + let uri5 = Services.io.newURI("http://t.foo/5"); + let uri6 = Services.io.newURI("http://t.foo/6"); + let uri7 = Services.io.newURI("http://t.foo/7"); + + await PlacesTestUtils.addVisits([ + { uri: uri7, title: "title" }, + { uri: uri6, title: "title" }, + { uri: uri4, title: "title" }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "title" }, + { uri: uri1, title: "title" }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri6, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri5, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri4, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri2, title: "title" }); + + await addOpenPages(uri7, 1); + + // Now remove page 6 from history, so it is an unvisited bookmark. + await PlacesUtils.history.remove(uri6); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // With the changes above, the sites in descending order of frecency are: + // uri2 + // uri4 + // uri5 + // uri6 + // uri1 + // uri3 + // uri7 + + info("Match everything"); + let context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri5.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri6.spec, + title: "title", + }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeTabSwitchResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Match only history"); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Drop-down empty search matches history sorted by frecency desc"); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + info("Empty search matches only bookmarks when history is disabled"); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri5.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri6.spec, + title: "title", + }), + ], + }); + + info( + "Empty search matches only open tabs when bookmarks and history are disabled" + ); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + // query is made explict so makeSearchResult doesn't trim it. + query: " ", + heuristic: true, + }), + makeTabSwitchResult(context, { uri: uri7.spec, title: "title" }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_encoded_urls.js b/browser/components/urlbar/tests/unit/test_encoded_urls.js new file mode 100644 index 0000000000..87a6015e86 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_encoded_urls.js @@ -0,0 +1,97 @@ +add_task(async function test_encoded() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext(url, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_trimmed() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("mozilla.com/search/top/?q=%25%32%35", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: "mozilla.com/search/top/?q=%25%32%35", + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_partial() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/search/top/?q=%25%32%35"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("https://www.mozilla.com/search/top/?q=%25", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_encoded_path() { + info("Searching for over encoded url should not break it"); + let url = "https://www.mozilla.com/%25%32%35/top/"; + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI(url), + title: url, + }); + let context = createContext("https://www.mozilla.com/%25%32%35/t", { + isPrivate: false, + }); + await check_results({ + context, + autofilled: url, + completed: url, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js b/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js new file mode 100644 index 0000000000..d330625bbb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_escaping_badEscapedURI.js @@ -0,0 +1,37 @@ +/* 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 bug 422277 to make sure bad escaped uris don't get escaped. This makes + * sure we don't hit an assertion for "not a UTF8 string". + */ + +testEngine_setup(); + +add_task(async function test() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("Bad escaped uri stays escaped"); + let uri1 = Services.io.newURI("http://site/%EAid"); + await PlacesTestUtils.addVisits([{ uri: uri1, title: "title" }]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "title", + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js b/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js new file mode 100644 index 0000000000..73fe3940c3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_escaping_escapeSelf.js @@ -0,0 +1,62 @@ +/* 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 bug 422698 to make sure searches with urls from the location bar + * correctly match itself when it contains escaped characters. + */ + +testEngine_setup(); + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://unescapeduri/"); + let uri2 = Services.io.newURI("http://escapeduri/%40/"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + + info("Unescaped location matches itself"); + let context = createContext("http://unescapeduri/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: uri1.spec, + title: "title", + iconUri: `page-icon:${uri1.spec}`, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + // Note that uri2 does not appear in results. + ], + }); + + info("Escaped location matches itself"); + context = createContext("http://escapeduri/%40", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "http://escapeduri/%40", + fallbackTitle: "http://escapeduri/%40", + iconUri: "page-icon:http://escapeduri/", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "title", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_exposure.js b/browser/components/urlbar/tests/unit/test_exposure.js new file mode 100644 index 0000000000..837673a868 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_exposure.js @@ -0,0 +1,195 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +// Tests that registering an exposureResults pref and triggering a match causes +// the exposure event to be recorded on the UrlbarResults. +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: ["test"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + }, + { + id: 2, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: ["non_sponsored"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "wikipedia", + iab_category: "5 - Education", + }, +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "test", + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +const EXPECTED_NON_SPONSORED_REMOTE_SETTINGS_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + qsSuggestion: "non_sponsored", + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 2, + sponsoredAdvertiser: "wikipedia", + sponsoredIabCategory: "5 - Education", + isSponsored: false, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +add_setup(async function test_setup() { + // FOG needs a profile directory to put its data in. + do_get_profile(); + + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); + + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", false); + + await MerinoTestUtils.server.start(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); +}); + +add_task(async function testExposureCheck() { + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + UrlbarPrefs.set("exposureResults", "rs_adm_sponsored"); + UrlbarPrefs.set("showExposureResults", true); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal(context.results[0].exposureResultType, "rs_adm_sponsored"); + Assert.equal(context.results[0].exposureResultHidden, false); +}); + +add_task(async function testExposureCheckMultiple() { + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + UrlbarPrefs.set("exposureResults", "rs_adm_sponsored,rs_adm_nonsponsored"); + UrlbarPrefs.set("showExposureResults", true); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal(context.results[0].exposureResultType, "rs_adm_sponsored"); + Assert.equal(context.results[0].exposureResultHidden, false); + + context = createContext("non_sponsored", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_NON_SPONSORED_REMOTE_SETTINGS_RESULT], + }); + + Assert.equal(context.results[0].exposureResultType, "rs_adm_nonsponsored"); + Assert.equal(context.results[0].exposureResultHidden, false); +}); + +add_task(async function exposureDisplayFiltering() { + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + UrlbarPrefs.set("exposureResults", "rs_adm_sponsored"); + UrlbarPrefs.set("showExposureResults", false); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + Assert.equal(context.results[0].exposureResultType, "rs_adm_sponsored"); + Assert.equal(context.results[0].exposureResultHidden, true); +}); diff --git a/browser/components/urlbar/tests/unit/test_frecency.js b/browser/components/urlbar/tests/unit/test_frecency.js new file mode 100644 index 0000000000..0d7a007e0d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_frecency.js @@ -0,0 +1,403 @@ +/* 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 bug 406358 to make sure frecency works for empty input/search, but + * this also tests for non-empty inputs as well. Because the interactions among + * DIFFERENT* visit counts and visit dates is not well defined, this test + * holds one of the two values constant when modifying the other. + * + * Also test bug 419068 to make sure tagged pages don't necessarily have to be + * first in the results. + * + * Also test bug 426166 to make sure that the results of autocomplete searches + * are stable. Note that failures of this test will be intermittent by nature + * since we are testing to make sure that the unstable sort algorithm used + * by SQLite is not changing the order of the results on us. + */ + +testEngine_setup(); + +async function task_setCountDate(uri, count, date) { + // We need visits so that frecency can be computed over multiple visits + let visits = []; + for (let i = 0; i < count; i++) { + visits.push({ + uri, + visitDate: date, + transition: PlacesUtils.history.TRANSITION_TYPED, + }); + } + await PlacesTestUtils.addVisits(visits); +} + +async function setBookmark(uri) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + url: uri, + title: "bleh", + }); +} + +async function tagURI(uri, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: uri, + title: "bleh", + }); + PlacesUtils.tagging.tagURI(uri, tags); +} + +var uri1 = Services.io.newURI("http://site.tld/1"); +var uri2 = Services.io.newURI("http://site.tld/2"); +var uri3 = Services.io.newURI("http://aaaaaaaaaa/1"); +var uri4 = Services.io.newURI("http://aaaaaaaaaa/2"); + +// d1 is younger (should show up higher) than d2 (PRTime is in usecs not msec) +// Make sure the dates fall into different frecency groups +var d1 = new Date(Date.now() - 1000 * 60 * 60) * 1000; +var d2 = new Date(Date.now() - 1000 * 60 * 60 * 24 * 10) * 1000; +// c1 is larger (should show up higher) than c2 +var c1 = 10; +var c2 = 1; + +var tests = [ + // test things without a search term + async function () { + info("Test 0: same count, different date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c1, d2); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + // uri1 is a visit result despite being a tagged bookmark because we + // are searching for the empty string. By default, the empty string + // filters to history. uri1 will be displayed as a bookmark later in the + // test when we are searching with a non-empty string. + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 1: same count, different date"); + await task_setCountDate(uri1, c1, d2); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + ], + }); + }, + async function () { + info("Test 2: different count, same date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c2, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 3: different count, same date"); + await task_setCountDate(uri1, c2, d1); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext(" ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: " ", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "bleh", + }), + ], + }); + }, + + // test things with a search term + async function () { + info("Test 4: same count, different date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c1, d2); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 5: same count, different date"); + await task_setCountDate(uri1, c1, d2); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + ], + }); + }, + async function () { + info("Test 6: different count, same date"); + await task_setCountDate(uri1, c1, d1); + await task_setCountDate(uri2, c2, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + ], + }); + }, + async function () { + info("Test 7: different count, same date"); + await task_setCountDate(uri1, c2, d1); + await task_setCountDate(uri2, c1, d1); + await tagURI(uri1, ["site"]); + let context = createContext("site", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: `test visit for ${uri2.spec}`, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "bleh", + tags: ["site"], + }), + ], + }); + }, + // There are multiple tests for 8, hence the multiple functions + // Bug 426166 section + async function () { + info("Test 8.1a: same count, same date"); + await setBookmark(uri3); + await setBookmark(uri4); + let context = createContext("a", { isPrivate: false }); + let bookmarkResults = [ + makeBookmarkResult(context, { + uri: uri4.spec, + title: "bleh", + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "bleh", + }), + ]; + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aa", { isPrivate: false }); + await check_results({ + context, + matches: [ + // We need to continuously redefine the heuristic search result because it + // is the only one that changes with the search string. + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aaa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("aa", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + + context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...bookmarkResults, + ], + }); + }, +]; + +add_task(async function test_frecency() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + for (let test of tests) { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + + await test(); + } + for (let type of [ + "history", + "bookmark", + "openpage", + "searches", + "engines", + "quickactions", + ]) { + Services.prefs.clearUserPref("browser.urlbar.suggest." + type); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js b/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js new file mode 100644 index 0000000000..c2d0e4bae3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_frecency_alternative_nimbus.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const ORIGINS_FEATUREGATE = "places.frecency.origins.alternative.featureGate"; +const ORIGINS_DAYSCUTOFF = "places.frecency.origins.alternative.daysCutOff"; + +add_task(async function () { + let reset = await UrlbarTestUtils.initNimbusFeature( + { + // Empty for sanity check. + }, + "frecency", + "config" + ); + Assert.equal(Services.prefs.getBoolPref(ORIGINS_FEATUREGATE), false); + Assert.throws( + () => Services.prefs.getIntPref(ORIGINS_DAYSCUTOFF), + /NS_ERROR_UNEXPECTED/ + ); + await reset(); + + reset = await UrlbarTestUtils.initNimbusFeature( + { + originsAlternativeEnable: true, + }, + "frecency", + "config" + ); + Assert.equal(Services.prefs.getBoolPref(ORIGINS_FEATUREGATE), true); + Assert.ok(Services.prefs.prefHasUserValue(ORIGINS_FEATUREGATE)); + Assert.throws( + () => Services.prefs.getIntPref(ORIGINS_DAYSCUTOFF), + /NS_ERROR_UNEXPECTED/ + ); + await reset(); + + reset = await UrlbarTestUtils.initNimbusFeature( + { + originsAlternativeEnable: true, + originsDaysCutOff: 60, + }, + "frecency", + "config" + ); + Assert.equal(Services.prefs.getBoolPref(ORIGINS_FEATUREGATE), true); + Assert.ok(Services.prefs.prefHasUserValue(ORIGINS_FEATUREGATE)); + Assert.equal(Services.prefs.getIntPref(ORIGINS_DAYSCUTOFF, 90), 60); + Assert.ok(Services.prefs.prefHasUserValue(ORIGINS_DAYSCUTOFF)); + await reset(); +}); diff --git a/browser/components/urlbar/tests/unit/test_heuristic_cancel.js b/browser/components/urlbar/tests/unit/test_heuristic_cancel.js new file mode 100644 index 0000000000..a53786a747 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_heuristic_cancel.js @@ -0,0 +1,238 @@ +/* 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/. */ + +/** + * Tests that old results from UrlbarProviderAutofill do not overwrite results + * from UrlbarProviderHeuristicFallback after the autofillable query is + * cancelled. See bug 1653436. + */ + +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", +}); + +/** + * A test provider that waits before returning results to simulate a slow DB + * lookup. + */ +class SlowHeuristicProvider extends TestProvider { + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, add) { + this._context = context; + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + for (let result of this._results) { + add(this, result); + } + } +} + +/** + * A fast provider that alerts the test when it has added its results. + */ +class FastHeuristicProvider extends TestProvider { + get type() { + return UrlbarUtils.PROVIDER_TYPE.HEURISTIC; + } + + async startQuery(context, add) { + this._context = context; + for (let result of this._results) { + add(this, result); + } + Services.obs.notifyObservers(null, "results-added"); + } +} + +add_task(async function setup() { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +/** + * Tests that UrlbarProvidersManager._heuristicProviderTimer is cancelled when + * a query is cancelled. + */ +add_task(async function timerIsCancelled() { + let context = createContext("m", { isPrivate: false }); + await PlacesTestUtils.promiseAsyncUpdates(); + info("Manually set up query and then overwrite it."); + // slowProvider is a stand-in for a slow UrlbarProviderPlaces returning a + // non-heuristic result. + let slowProvider = new SlowHeuristicProvider({ + results: [ + makeVisitResult(context, { + uri: `http://mozilla.org/`, + title: `mozilla.org/`, + }), + ], + }); + UrlbarProvidersManager.registerProvider(slowProvider); + + // fastProvider is a stand-in for a fast Autofill returning a heuristic + // result. + let fastProvider = new FastHeuristicProvider({ + results: [ + makeVisitResult(context, { + uri: `http://mozilla.com/`, + title: `mozilla.com/`, + heuristic: true, + }), + ], + }); + UrlbarProvidersManager.registerProvider(fastProvider); + let firstContext = createContext("m", { + providers: [slowProvider.name, fastProvider.name], + }); + let secondContext = createContext("ma", { + providers: [slowProvider.name, fastProvider.name], + }); + + let controller = UrlbarTestUtils.newMockController(); + let queryRecieved, queryCancelled; + const controllerListener = { + onQueryResults(queryContext) { + Assert.equal( + queryContext, + secondContext, + "Only the second query should finish." + ); + queryRecieved = true; + }, + onQueryCancelled(queryContext) { + Assert.equal( + queryContext, + firstContext, + "The first query should be cancelled." + ); + Assert.ok(!queryCancelled, "No more than one query should be cancelled."); + queryCancelled = true; + }, + }; + controller.addQueryListener(controllerListener); + + // Wait until FastProvider sends its results to the providers manager. + // Then they will be queued up in a _heuristicProvidersTimer, waiting for + // the results from SlowProvider. + let resultsAddedPromise = new Promise(resolve => { + let observe = async (subject, topic, data) => { + Services.obs.removeObserver(observe, "results-added"); + // Fire the second query to cancel the first. + await controller.startQuery(secondContext); + resolve(); + }; + + Services.obs.addObserver(observe, "results-added"); + }); + + controller.startQuery(firstContext); + await resultsAddedPromise; + + Assert.ok(queryCancelled, "At least one query was cancelled."); + Assert.ok(queryRecieved, "At least one query finished."); + controller.removeQueryListener(controllerListener); +}); + +/** + * Tests that old autofill results aren't displayed after a query is cancelled. + * See bug 1653436. + */ +add_task(async function autofillIsCleared() { + /** + * Steps: + * 1. Start query. + * 2. Allow UrlbarProviderAutofill to start _getAutofillResult. + * 3. Execute a new query with no autofill match, cancelling the first + * query. + * 4. Test that the old result from UrlbarProviderAutofill isn't displayed. + */ + await PlacesTestUtils.addVisits("http://example.com"); + + let firstContext = createContext("e", { + providers: ["Autofill", "HeuristicFallback"], + }); + let secondContext = createContext("em", { + providers: ["Autofill", "HeuristicFallback"], + }); + + info("Sanity check: The first query autofills and the second does not."); + await check_results({ + firstContext, + autofilled: "example.com", + completed: "http://example.com/", + matches: [ + makeVisitResult(firstContext, { + uri: "http://example.com/", + title: "example.com", + heuristic: true, + }), + ], + }); + + await check_results({ + secondContext, + matches: [ + makeSearchResult(secondContext, { + engineName: (await Services.search.getDefault()).name, + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); + + // Refresh our queries + firstContext = createContext("e", { + providers: ["Autofill", "HeuristicFallback"], + }); + secondContext = createContext("em", { + providers: ["Autofill", "HeuristicFallback"], + }); + + // Set up controller to observe queries. + let controller = UrlbarTestUtils.newMockController(); + let queryRecieved, queryCancelled; + const controllerListener = { + onQueryResults(queryContext) { + Assert.equal( + queryContext, + secondContext, + "Only the second query should finish." + ); + queryRecieved = true; + }, + onQueryCancelled(queryContext) { + Assert.equal( + queryContext, + firstContext, + "The first query should be cancelled." + ); + Assert.ok( + !UrlbarProviderAutofill._autofillData, + "The first result should not have populated autofill data." + ); + Assert.ok(!queryCancelled, "No more than one query should be cancelled."); + queryCancelled = true; + }, + }; + controller.addQueryListener(controllerListener); + + // Intentionally do not await this first query. + controller.startQuery(firstContext); + await controller.startQuery(secondContext); + + Assert.ok(queryCancelled, "At least one query was cancelled."); + Assert.ok(queryRecieved, "At least one query finished."); + controller.removeQueryListener(controllerListener); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js b/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js new file mode 100644 index 0000000000..d94a655b22 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_hideSponsoredHistory.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This tests the muxer functionality that hides URLs in history that were +// originally sponsored. + +"use strict"; + +add_task(async function test() { + // Disable search suggestions to avoid hitting the network. + UrlbarPrefs.set("suggest.searches", false); + + let engine = await Services.search.getDefault(); + let pref = "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam"; + + // This maps URL search params to objects describing whether a URL with those + // params is expected to appear in the search results. Each inner object maps + // from a value of the pref to whether the URL is expected to appear given the + // pref value. + let tests = { + "": { + "": true, + test: true, + "test=": true, + "test=hide": true, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + test: { + "": true, + test: false, + "test=": false, + "test=hide": true, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + "test=hide": { + "": true, + test: false, + "test=": true, + "test=hide": false, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + "test=foo&test=hide": { + "": true, + test: false, + "test=": true, + "test=hide": false, + nomatch: true, + "nomatch=": true, + "nomatch=hide": true, + }, + }; + + for (let [urlParams, expected] of Object.entries(tests)) { + for (let [prefValue, shouldAppear] of Object.entries(expected)) { + info( + "Running test: " + + JSON.stringify({ urlParams, prefValue, shouldAppear }) + ); + + // Add a visit to a URL with search params `urlParams`. + let url = new URL("http://example.com/"); + url.search = urlParams; + await PlacesTestUtils.addVisits(url); + + // Set the pref to `prefValue`. + Services.prefs.setCharPref(pref, prefValue); + + // Set up the context and expected results. If `shouldAppear` is true, a + // visit result for the URL should appear. + let context = createContext("ample", { isPrivate: false }); + let expectedResults = [ + makeSearchResult(context, { + heuristic: true, + engineName: engine.name, + engineIconUri: engine.iconURI?.spec, + }), + ]; + if (shouldAppear) { + expectedResults.push( + makeVisitResult(context, { + uri: url.toString(), + title: "test visit for " + url, + }) + ); + } + + // Do a search and check the results. + await check_results({ + context, + matches: expectedResults, + }); + + await PlacesUtils.history.clear(); + } + } + + Services.prefs.clearUserPref(pref); +}); diff --git a/browser/components/urlbar/tests/unit/test_keywords.js b/browser/components/urlbar/tests/unit/test_keywords.js new file mode 100644 index 0000000000..de60742b81 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_keywords.js @@ -0,0 +1,212 @@ +/* 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/. */ + +testEngine_setup(); + +add_task(async function test_non_keyword() { + info("Searching for non-keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + let context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_keyword() { + info("Searching for keyworded entry should not autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.org/test/", + title: "http://mozilla.org/test/", + keyword: "moz", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_more_than_keyword() { + info("Searching for more than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_less_than_keyword() { + info("Searching for less than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + search: "mo", + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + fallbackTitle: "mozilla.org", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://mozilla.org/test/", + title: "A bookmark", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_keyword_casing() { + info("Searching for keyworded entry is case-insensitive"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.org/test/"), + keyword: "moz", + }); + let context = createContext("MoZ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.org/test/", + title: "http://mozilla.org/test/", + keyword: "MoZ", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_less_then_equal_than_keyword_bug_1124238() { + info("Searching for less than keyworded entry should autoFill it"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://mozilla.org/test/"), + }); + await PlacesTestUtils.addVisits("http://mozilla.com/"); + PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI("http://mozilla.com/"), + keyword: "moz", + }); + + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + search: "mo", + autofilled: "mozilla.com/", + completed: "http://mozilla.com/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.com/", + title: "test visit for http://mozilla.com/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + // Search with an additional character. As the input matches a keyword, the + // completion should equal the keyword and not the URI as before. + context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://mozilla.com/", + title: "http://mozilla.com/", + keyword: "moz", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + // Search with an additional character. The input doesn't match a keyword + // anymore, it should be autofilled. + context = createContext("mozi", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.com/", + completed: "http://mozilla.com/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.com/", + title: "A bookmark", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_l10nCache.js b/browser/components/urlbar/tests/unit/test_l10nCache.js new file mode 100644 index 0000000000..e92c75fa01 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_l10nCache.js @@ -0,0 +1,685 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests L10nCache in UrlbarUtils.jsm. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + L10nCache: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +add_task(async function comprehensive() { + // Set up a mock localization. + let l10n = initL10n({ + args0a: "Zero args value", + args0b: "Another zero args value", + args1a: "One arg value is { $arg1 }", + args1b: "Another one arg value is { $arg1 }", + args2a: "Two arg values are { $arg1 } and { $arg2 }", + args2b: "More two arg values are { $arg1 } and { $arg2 }", + args3a: "Three arg values are { $arg1 }, { $arg2 }, and { $arg3 }", + args3b: "More three arg values are { $arg1 }, { $arg2 }, and { $arg3 }", + attrs1: [".label = attrs1 label has zero args"], + attrs2: [ + ".label = attrs2 label has zero args", + ".tooltiptext = attrs2 tooltiptext arg value is { $arg1 }", + ], + attrs3: [ + ".label = attrs3 label has zero args", + ".tooltiptext = attrs3 tooltiptext arg value is { $arg1 }", + ".alt = attrs3 alt arg values are { $arg1 } and { $arg2 }", + ], + }); + + let tests = [ + // different strings with the same number of args and also the same strings + // with different args + { + obj: { + id: "args0a", + }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + { + obj: { + id: "args0b", + }, + expected: { + value: "Another zero args value", + attributes: null, + }, + }, + { + obj: { + id: "args1a", + args: { arg1: "foo1" }, + }, + expected: { + value: "One arg value is foo1", + attributes: null, + }, + }, + { + obj: { + id: "args1a", + args: { arg1: "foo2" }, + }, + expected: { + value: "One arg value is foo2", + attributes: null, + }, + }, + { + obj: { + id: "args1b", + args: { arg1: "foo1" }, + }, + expected: { + value: "Another one arg value is foo1", + attributes: null, + }, + }, + { + obj: { + id: "args1b", + args: { arg1: "foo2" }, + }, + expected: { + value: "Another one arg value is foo2", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "foo1", arg2: "bar1" }, + }, + expected: { + value: "Two arg values are foo1 and bar1", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "foo2", arg2: "bar2" }, + }, + expected: { + value: "Two arg values are foo2 and bar2", + attributes: null, + }, + }, + { + obj: { + id: "args2b", + args: { arg1: "foo1", arg2: "bar1" }, + }, + expected: { + value: "More two arg values are foo1 and bar1", + attributes: null, + }, + }, + { + obj: { + id: "args2b", + args: { arg1: "foo2", arg2: "bar2" }, + }, + expected: { + value: "More two arg values are foo2 and bar2", + attributes: null, + }, + }, + { + obj: { + id: "args3a", + args: { arg1: "foo1", arg2: "bar1", arg3: "baz1" }, + }, + expected: { + value: "Three arg values are foo1, bar1, and baz1", + attributes: null, + }, + }, + { + obj: { + id: "args3a", + args: { arg1: "foo2", arg2: "bar2", arg3: "baz2" }, + }, + expected: { + value: "Three arg values are foo2, bar2, and baz2", + attributes: null, + }, + }, + { + obj: { + id: "args3b", + args: { arg1: "foo1", arg2: "bar1", arg3: "baz1" }, + }, + expected: { + value: "More three arg values are foo1, bar1, and baz1", + attributes: null, + }, + }, + { + obj: { + id: "args3b", + args: { arg1: "foo2", arg2: "bar2", arg3: "baz2" }, + }, + expected: { + value: "More three arg values are foo2, bar2, and baz2", + attributes: null, + }, + }, + + // two instances of the same string with their args swapped + { + obj: { + id: "args2a", + args: { arg1: "arg A", arg2: "arg B" }, + }, + expected: { + value: "Two arg values are arg A and arg B", + attributes: null, + }, + }, + { + obj: { + id: "args2a", + args: { arg1: "arg B", arg2: "arg A" }, + }, + expected: { + value: "Two arg values are arg B and arg A", + attributes: null, + }, + }, + + // strings with attributes + { + obj: { + id: "attrs1", + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + }, + }, + }, + { + obj: { + id: "attrs2", + args: { + arg1: "arg A", + }, + }, + expected: { + value: null, + attributes: { + label: "attrs2 label has zero args", + tooltiptext: "attrs2 tooltiptext arg value is arg A", + }, + }, + }, + { + obj: { + id: "attrs3", + args: { + arg1: "arg A", + arg2: "arg B", + }, + }, + expected: { + value: null, + attributes: { + label: "attrs3 label has zero args", + tooltiptext: "attrs3 tooltiptext arg value is arg A", + alt: "attrs3 alt arg values are arg A and arg B", + }, + }, + }, + ]; + + let cache = new L10nCache(l10n); + + // Get some non-cached strings. + Assert.ok(!cache.get({ id: "uncached1" }), "Uncached string 1"); + Assert.ok(!cache.get({ id: "uncached2", args: "foo" }), "Uncached string 2"); + + // Add each test string and get it back. + for (let { obj, expected } of tests) { + await cache.add(obj); + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Get each string again to make sure each add didn't somehow mess up the + // previously added strings. + for (let { obj, expected } of tests) { + Assert.deepEqual( + cache.get(obj), + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Delete some of the strings. We'll delete every other one to mix it up. + for (let i = 0; i < tests.length; i++) { + if (i % 2 == 0) { + let { obj } = tests[i]; + cache.delete(obj); + Assert.ok(!cache.get(obj), "Deleted from cache: " + JSON.stringify(obj)); + } + } + + // Get each remaining string. + for (let i = 0; i < tests.length; i++) { + if (i % 2 != 0) { + let { obj, expected } = tests[i]; + Assert.deepEqual( + cache.get(obj), + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + } + + // Clear the cache. + cache.clear(); + for (let { obj } of tests) { + Assert.ok(!cache.get(obj), "After cache clear: " + JSON.stringify(obj)); + } + + // `ensure` each test string and get it back. + for (let { obj, expected } of tests) { + await cache.ensure(obj); + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + + // Call `ensure` again. This time, `add` should not be called. + let originalAdd = cache.add; + cache.add = () => Assert.ok(false, "add erroneously called"); + await cache.ensure(obj); + cache.add = originalAdd; + } + + // Clear the cache again. + cache.clear(); + for (let { obj } of tests) { + Assert.ok(!cache.get(obj), "After cache clear: " + JSON.stringify(obj)); + } + + // `ensureAll` the test strings and get them back. + let objects = tests.map(({ obj }) => obj); + await cache.ensureAll(objects); + for (let { obj, expected } of tests) { + let message = cache.get(obj); + Assert.deepEqual( + message, + expected, + "Expected message for obj: " + JSON.stringify(obj) + ); + } + + // Ensure the cache is cleared after the app locale changes + Assert.greater(cache.size(), 0, "The cache has messages in it."); + Services.obs.notifyObservers(null, "intl:app-locales-changed"); + await l10n.ready; + Assert.equal(cache.size(), 0, "The cache is empty on app locale change"); +}); + +// Tests the `excludeArgsFromCacheKey` option. +add_task(async function excludeArgsFromCacheKey() { + // Set up a mock localization. + let l10n = initL10n({ + args0: "Zero args value", + args1: "One arg value is { $arg1 }", + attrs0: [".label = attrs0 label has zero args"], + attrs1: [ + ".label = attrs1 label has zero args", + ".tooltiptext = attrs1 tooltiptext arg value is { $arg1 }", + ], + }); + + let cache = new L10nCache(l10n); + + // Test cases. For each test case, we cache a string using one or more + // methods, `cache.add({ excludeArgsFromCacheKey: true })` and/or + // `cache.ensure({ excludeArgsFromCacheKey: true })`. After calling each + // method, we call `cache.get()` to get the cached string. + // + // Test cases are cumulative, so when `cache.add()` is called for a string and + // then `cache.ensure()` is called for the same string but with different l10n + // argument values, the string should be re-cached with the new values. + // + // Each item in the tests array is: `{ methods, obj, gets }` + // + // {array} methods + // Array of cache method names, one or more of: "add", "ensure" + // Methods are called in the order they are listed. + // {object} obj + // An l10n object that will be passed to the cache methods: + // `{ id, args, excludeArgsFromCacheKey }` + // {array} gets + // An array of objects that describes a series of calls to `cache.get()` and + // the expected return values: `{ obj, expected }` + // + // {object} obj + // An l10n object that will be passed to `cache.get():` + // `{ id, args, excludeArgsFromCacheKey }` + // {object} expected + // The expected return value from `get()`. + let tests = [ + // args0: string with no args and no attributes + { + methods: ["add", "ensure"], + obj: { + id: "args0", + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args0" }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + { + obj: { id: "args0", excludeArgsFromCacheKey: true }, + expected: { + value: "Zero args value", + attributes: null, + }, + }, + ], + }, + + // args1: string with one arg and no attributes + { + methods: ["add"], + obj: { + id: "args1", + args: { arg1: "ADD" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args1" }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { id: "args1", excludeArgsFromCacheKey: true }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: "One arg value is ADD", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + { + methods: ["ensure"], + obj: { + id: "args1", + args: { arg1: "ENSURE" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "args1" }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { id: "args1", excludeArgsFromCacheKey: true }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: "One arg value is ENSURE", + attributes: null, + }, + }, + { + obj: { + id: "args1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + + // attrs0: string with no args and one attribute + { + methods: ["add", "ensure"], + obj: { + id: "attrs0", + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs0" }, + expected: { + value: null, + attributes: { + label: "attrs0 label has zero args", + }, + }, + }, + { + obj: { id: "attrs0", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs0 label has zero args", + }, + }, + }, + ], + }, + + // attrs1: string with one arg and two attributes + { + methods: ["add"], + obj: { + id: "attrs1", + args: { arg1: "ADD" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs1" }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { id: "attrs1", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ADD", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + { + methods: ["ensure"], + obj: { + id: "attrs1", + args: { arg1: "ENSURE" }, + excludeArgsFromCacheKey: true, + }, + gets: [ + { + obj: { id: "attrs1" }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { id: "attrs1", excludeArgsFromCacheKey: true }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + excludeArgsFromCacheKey: true, + }, + expected: { + value: null, + attributes: { + label: "attrs1 label has zero args", + tooltiptext: "attrs1 tooltiptext arg value is ENSURE", + }, + }, + }, + { + obj: { + id: "attrs1", + args: { arg1: "some other value" }, + }, + expected: undefined, + }, + ], + }, + ]; + + let sandbox = sinon.createSandbox(); + let spy = sandbox.spy(cache, "add"); + + for (let { methods, obj, gets } of tests) { + for (let method of methods) { + info(`Calling method '${method}' with l10n obj: ` + JSON.stringify(obj)); + await cache[method](obj); + + // `add()` should always be called: We either just called it directly, or + // `ensure({ excludeArgsFromCacheKey: true })` called it. + Assert.ok( + spy.calledOnce, + "add() should have been called once: " + JSON.stringify(obj) + ); + spy.resetHistory(); + + for (let { obj: getObj, expected } of gets) { + Assert.deepEqual( + cache.get(getObj), + expected, + "Expected message for get: " + JSON.stringify(getObj) + ); + } + } + } + + sandbox.restore(); +}); + +/** + * Sets up a mock localization. + * + * @param {object} pairs + * Fluent strings as key-value pairs. + * @returns {Localization} + * The mock Localization object. + */ +function initL10n(pairs) { + let source = Object.entries(pairs) + .map(([key, value]) => { + if (Array.isArray(value)) { + value = value.map(s => " \n" + s).join(""); + } + return `${key} = ${value}`; + }) + .join("\n"); + let registry = new L10nRegistry(); + registry.registerSources([ + L10nFileSource.createMock( + "test", + "app", + ["en-US"], + "/localization/{locale}", + [{ source, path: "/localization/en-US/test.ftl" }] + ), + ]); + return new Localization(["/test.ftl"], true, registry, ["en-US"]); +} diff --git a/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js b/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js new file mode 100644 index 0000000000..2d03cc4c54 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_local_suggest_prefs.js @@ -0,0 +1,126 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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 following preferences related to local suggest. +// * browser.urlbar.suggest.bookmark +// * browser.urlbar.suggest.history +// * browser.urlbar.suggest.openpage + +testEngine_setup(); + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + + const uri = Services.io.newURI("http://example.com/"); + + await PlacesTestUtils.addVisits([{ uri, title: "example" }]); + await PlacesUtils.bookmarks.insert({ + url: uri, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + await addOpenPages(uri); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + await cleanupPlaces(); + }); +}); + +add_task(async function test_prefs() { + const testData = [ + { + bookmark: true, + history: true, + openpage: true, + }, + { + bookmark: false, + history: true, + openpage: true, + }, + { + bookmark: true, + history: false, + openpage: true, + }, + { + bookmark: true, + history: true, + openpage: false, + }, + { + bookmark: false, + history: false, + openpage: true, + }, + { + bookmark: false, + history: true, + openpage: false, + }, + { + bookmark: true, + history: false, + openpage: false, + }, + { + bookmark: false, + history: false, + openpage: false, + }, + ]; + + for (const { bookmark, history, openpage } of testData) { + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", bookmark); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", history); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", openpage); + + info(`Test bookmark:${bookmark} history:${history} openpage:${openpage}`); + + const context = createContext("e", { isPrivate: false }); + const matches = []; + + matches.push( + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }) + ); + + if (openpage) { + matches.push( + makeTabSwitchResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } else if (bookmark) { + matches.push( + makeBookmarkResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } else if (history) { + matches.push( + makeVisitResult(context, { + uri: "http://example.com/", + title: "example", + }) + ); + } + + await check_results({ context, matches }); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_match_javascript.js b/browser/components/urlbar/tests/unit/test_match_javascript.js new file mode 100644 index 0000000000..6cf7126d41 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_match_javascript.js @@ -0,0 +1,155 @@ +/* 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 bug 417798 to make sure javascript: URIs don't show up unless the + * user searches for javascript: explicitly. + */ + +testEngine_setup(); + +add_task(async function test_javascript_match() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + }); + + let uri1 = Services.io.newURI("http://abc/def"); + let uri2 = Services.io.newURI("javascript:5"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Title with javascript:", + }); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "Title with javascript:" }, + ]); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match non-javascript: with plain search"); + let context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript: with 'javascript'"); + context = createContext("javascript", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript with 'javascript:'"); + context = createContext("javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match nothing with '5 javascript:'"); + context = createContext("5 javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Match non-javascript: with 'a javascript:'"); + context = createContext("a javascript:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + ], + }); + + info("Match non-javascript: and javascript: with 'javascript: a'"); + context = createContext("javascript: a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "javascript: a", + fallbackTitle: "javascript: a", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "Title with javascript:", + }), + makeBookmarkResult(context, { + uri: uri2.spec, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title: "Title with javascript:", + }), + ], + }); + + info("Match javascript: with 'javascript: 5'"); + context = createContext("javascript: 5", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: "javascript: 5", + fallbackTitle: "javascript: 5", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + iconUri: "chrome://global/skin/icons/defaultFavicon.svg", + title: "Title with javascript:", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_multi_word_search.js b/browser/components/urlbar/tests/unit/test_multi_word_search.js new file mode 100644 index 0000000000..7054feb8aa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_multi_word_search.js @@ -0,0 +1,126 @@ +/* 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 bug 401869 to allow multiple words separated by spaces to match in + * the page title, page url, or bookmark title to be considered a match. All + * terms must match but not all terms need to be in the title, etc. + * + * Test bug 424216 by making sure bookmark titles are always shown if one is + * available. Also bug 425056 makes sure matches aren't found partially in the + * page title and partially in the bookmark. + */ + +testEngine_setup(); + +add_task(async function test_match_beginning() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://a.b.c/d-e_f/h/t/p"); + let uri2 = Services.io.newURI("http://d.e.f/g-h_i/h/t/p"); + let uri3 = Services.io.newURI("http://g.h.i/j-k_l/h/t/p"); + let uri4 = Services.io.newURI("http://j.k.l/m-n_o/h/t/p"); + await PlacesTestUtils.addVisits([ + { uri: uri4, title: "f(o)o b<a>r" }, + { uri: uri3, title: "f(o)o b<a>r" }, + { uri: uri2, title: "b(a)r b<a>z" }, + { uri: uri1, title: "f(o)o b<a>r" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "f(o)o b<a>r", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "b(a)r b<a>z", + }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match 2 terms all in url"); + let context = createContext("c d", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "f(o)o b<a>r" }), + ], + }); + + info("Match 1 term in url and 1 term in title"); + context = createContext("b e", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "f(o)o b<a>r" }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r b<a>z" }), + ], + }); + + info("Match 3 terms all in title; display bookmark title if matched"); + context = createContext("b a z", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { uri: uri4.spec, title: "b(a)r b<a>z" }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r b<a>z" }), + ], + }); + + info( + "Match 2 terms in url and 1 in title; make sure bookmark title is used for search" + ); + context = createContext("k f t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { uri: uri3.spec, title: "f(o)o b<a>r" }), + ], + }); + + info("Match 3 terms in url and 1 in title"); + context = createContext("d i g z", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri2.spec, title: "b(a)r b<a>z" }), + ], + }); + + info("Match nothing"); + context = createContext("m o z i", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_muxer.js b/browser/components/urlbar/tests/unit/test_muxer.js new file mode 100644 index 0000000000..dcea820835 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_muxer.js @@ -0,0 +1,731 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +let sandbox; + +add_task(async function setup() { + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test_muxer() { + Assert.throws( + () => UrlbarProvidersManager.registerMuxer(), + /invalid muxer/, + "Should throw with no arguments" + ); + Assert.throws( + () => UrlbarProvidersManager.registerMuxer({}), + /invalid muxer/, + "Should throw with empty object" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerMuxer({ + name: "", + }), + /invalid muxer/, + "Should throw with empty name" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerMuxer({ + name: "test", + sort: "no", + }), + /invalid muxer/, + "Should throw with invalid sort" + ); + + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/tab/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/bookmark/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/history/" } + ), + ]; + + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + /** + * A test muxer. + */ + class TestMuxer extends UrlbarMuxer { + get name() { + return "TestMuxer"; + } + sort(queryContext, unsortedResults) { + queryContext.results = [...unsortedResults].sort((a, b) => { + if (b.source == UrlbarUtils.RESULT_SOURCE.TABS) { + return -1; + } + if (b.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + return 1; + } + return a.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS ? -1 : 1; + }); + } + } + let muxer = new TestMuxer(); + + UrlbarProvidersManager.registerMuxer(muxer); + context.muxer = "TestMuxer"; + + info("Check results, the order should be: bookmark, history, tab"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [matches[1], matches[2], matches[0]]); + + // Sanity check, should not throw. + UrlbarProvidersManager.unregisterMuxer(muxer); + UrlbarProvidersManager.unregisterMuxer("TestMuxer"); // no-op. +}); + +add_task(async function test_preselectedHeuristic_singleProvider() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + matches[1].heuristic = true; + + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: b (heuristic), a, c"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [matches[1], matches[0], matches[2]]); +}); + +add_task(async function test_preselectedHeuristic_multiProviders() { + let matches1 = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + + let matches2 = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/d" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/e" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/f" } + ), + ]; + matches2[1].heuristic = true; + + let provider1 = registerBasicTestProvider(matches1); + let provider2 = registerBasicTestProvider(matches2); + + let context = createContext(undefined, { + providers: [provider1.name, provider2.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: e (heuristic), a, b, c, d, f"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [ + matches2[1], + ...matches1, + matches2[0], + matches2[2], + ]); +}); + +add_task(async function test_suggestions() { + Services.prefs.setIntPref("browser.urlbar.maxHistoricalSearchSuggestions", 1); + + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/b" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "mozSearch", + query: "moz", + suggestion: "mozzarella", + lowerCaseSuggestion: "mozzarella", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "mozSearch", + query: "moz", + suggestion: "mozilla", + lowerCaseSuggestion: "mozilla", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "mozSearch", + query: "moz", + providesSearchMode: true, + keyword: "@moz", + } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/c" } + ), + ]; + + let provider = registerBasicTestProvider(matches); + + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Check results, the order should be: mozzarella, moz, a, b, @moz, c"); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [ + matches[2], + matches[3], + matches[0], + matches[1], + matches[4], + matches[5], + ]); + + Services.prefs.clearUserPref("browser.urlbar.maxHistoricalSearchSuggestions"); +}); + +add_task(async function test_deduplicate_for_unitConversion() { + const searchSuggestion = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "Google", + query: "10cm to m", + suggestion: "= 0.1 meters", + } + ); + const searchProvider = registerBasicTestProvider( + [searchSuggestion], + null, + UrlbarUtils.PROVIDER_TYPE.PROFILE + ); + + const unitConversionSuggestion = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + dynamicType: "unitConversion", + output: "0.1 m", + input: "10cm to m", + } + ); + unitConversionSuggestion.suggestedIndex = 1; + + const unitConversion = registerBasicTestProvider( + [unitConversionSuggestion], + null, + UrlbarUtils.PROVIDER_TYPE.PROFILE, + "UnitConversion" + ); + + const context = createContext(undefined, { + providers: [searchProvider.name, unitConversion.name], + }); + const controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, [unitConversionSuggestion]); +}); + +// These results are used in the badHeuristicGroups tests below. The order of +// the results in the array isn't important because they all get added at the +// same time. It's the resultGroups in each test that is important. +const BAD_HEURISTIC_RESULTS = [ + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/heuristic-0" } + ), + { heuristic: true } + ), + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/heuristic-1" } + ), + { heuristic: true } + ), + // non-heuristic + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/non-heuristic-0" } + ), + // non-heuristic + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/non-heuristic-1" } + ), +]; + +const BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC = BAD_HEURISTIC_RESULTS[0]; +const BAD_HEURISTIC_RESULTS_GENERAL = [ + BAD_HEURISTIC_RESULTS[2], + BAD_HEURISTIC_RESULTS[3], +]; + +add_task(async function test_badHeuristicGroups_multiple_0() { + await doBadHeuristicGroupsTest( + [ + // 2 heuristics with child groups + { + maxResultCount: 2, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_1() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_2() { + await doBadHeuristicGroupsTest( + [ + // 2 heuristics + { + maxResultCount: 2, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_3() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_4() { + await doBadHeuristicGroupsTest( + [ + // 1 heuristic with child groups + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic with child groups + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_5() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics with child groups + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_6() { + await doBadHeuristicGroupsTest( + [ + // 1 heuristic + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicGroups_multiple_7() { + await doBadHeuristicGroupsTest( + [ + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [BAD_HEURISTIC_RESULTS_FIRST_HEURISTIC, ...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_0() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic with child groups second + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_1() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics with child groups second + { + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_2() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 heuristic second + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_3() { + await doBadHeuristicGroupsTest( + [ + // infinite general first + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics second + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +add_task(async function test_badHeuristicsGroups_notFirst_4() { + await doBadHeuristicGroupsTest( + [ + // 1 general first + { + maxResultCount: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // infinite heuristics second + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + }, + // infinite general third + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + [...BAD_HEURISTIC_RESULTS_GENERAL] + ); +}); + +/** + * Sets the resultGroups pref, performs a search, and then checks the results. + * Regardless of the groups, the muxer should include at most one heuristic in + * its results and it should always be the first result. + * + * @param {Array} resultGroups + * The result groups. + * @param {Array} expectedResults + * The expected results. + */ +async function doBadHeuristicGroupsTest(resultGroups, expectedResults) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => { + return { children: resultGroups }; + }); + + let provider = registerBasicTestProvider(BAD_HEURISTIC_RESULTS); + let context = createContext("foo", { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, expectedResults); + + sandbox.restore(); +} + +// When `maxRichResults` is positive and taken up by suggested-index result(s), +// both the heuristic and suggested-index results should be included because we +// (a) make room for the heuristic and (b) assume all suggested-index results +// should be included even if it means exceeding `maxRichResults`. The specified +// `maxRichResults` span will be exceeded in this case. +add_task(async function roomForHeuristic_suggestedIndex() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/suggestedIndex" } + ), + { suggestedIndex: 1 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 1); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: results, + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is positive but less than the heuristic's result span, +// the heuristic should be included because we make room for it even if it means +// exceeding `maxRichResults`. The specified `maxRichResults` span will be +// exceeded in this case. +add_task(async function roomForHeuristic_largeResultSpan() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true, resultSpan: 2 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 1); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: results, + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is zero and there are no suggested-index results, the +// heuristic should not be included. +add_task(async function roomForHeuristic_maxRichResultsZero() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 0); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: [], + }); + + UrlbarPrefs.clear("maxRichResults"); +}); + +// When `maxRichResults` is zero and suggested-index results are present, +// neither the heuristic nor the suggested-index results should be included. +add_task(async function roomForHeuristic_maxRichResultsZero_suggestedIndex() { + let results = [ + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/heuristic" } + ), + { heuristic: true } + ), + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/suggestedIndex" } + ), + { suggestedIndex: 1 } + ), + ]; + + UrlbarPrefs.set("maxRichResults", 0); + + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await check_results({ + context, + matches: [], + }); + + UrlbarPrefs.clear("maxRichResults"); +}); diff --git a/browser/components/urlbar/tests/unit/test_protocol_ignore.js b/browser/components/urlbar/tests/unit/test_protocol_ignore.js new file mode 100644 index 0000000000..2e5096cb46 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_protocol_ignore.js @@ -0,0 +1,42 @@ +/* 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 bug 424509 to make sure searching for "h" doesn't match "http" of urls. + */ + +testEngine_setup(); + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let uri1 = Services.io.newURI("http://site/"); + let uri2 = Services.io.newURI("http://happytimes/"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + + info("Searching for h matches site and not http://"); + let context = createContext("h", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "title", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_protocol_swap.js b/browser/components/urlbar/tests/unit/test_protocol_swap.js new file mode 100644 index 0000000000..980904001c --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_protocol_swap.js @@ -0,0 +1,303 @@ +/* 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 bug 424717 to make sure searching with an existing location like + * http://site/ also matches https://site/ or ftp://site/. Same thing for + * ftp://site/ and https://site/. + * + * Test bug 461483 to make sure a search for "w" doesn't match the "www." from + * site subdomains. + */ + +testEngine_setup(); + +add_task(async function test_swap_protocol() { + let uri1 = Services.io.newURI("http://www.site/"); + let uri2 = Services.io.newURI("http://site/"); + let uri3 = Services.io.newURI("ftp://ftp.site/"); + let uri4 = Services.io.newURI("ftp://site/"); + let uri5 = Services.io.newURI("https://www.site/"); + let uri6 = Services.io.newURI("https://site/"); + let uri7 = Services.io.newURI("http://woohoo/"); + let uri8 = Services.io.newURI("http://wwwwwwacko/"); + await PlacesTestUtils.addVisits([ + { uri: uri8, title: "title" }, + { uri: uri7, title: "title" }, + { uri: uri6, title: "title" }, + { uri: uri5, title: "title" }, + { uri: uri4, title: "title" }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "title" }, + { uri: uri1, title: "title" }, + ]); + + // Disable autoFill to avoid handling the first result. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("http://www.site matches 'www.site' pages"); + let searchString = "http://www.site"; + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("http://site matches all sites"); + searchString = "http://site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("ftp://ftp.site matches itself"); + searchString = "ftp://ftp.site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("ftp://site matches all sites"); + searchString = "ftp://site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("https://www.site matches all sites"); + searchString = "https://www.sit"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("https://site matches all sites"); + searchString = "https://sit"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "title" }), + makeVisitResult(context, { uri: uri6.spec, title: "title" }), + ], + }); + + info("www.site matches 'www.site' pages"); + searchString = "www.site"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `http://${searchString}/`, + title: "title", + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + ], + }); + + info("w matches 'w' pages, including 'www'"); + context = createContext("w", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://w matches 'w' pages, including 'www'"); + searchString = "http://w"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri7.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.w matches nothing"); + searchString = "http://www.w"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + info("ww matches no 'ww' pages, including 'www'"); + context = createContext("ww", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://ww matches no 'ww' pages, including 'www'"); + searchString = "http://ww"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.ww matches nothing"); + searchString = "http://www.ww"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + info("www matches 'www' pages"); + context = createContext("www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www matches 'www' pages"); + searchString = "http://www"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri5.spec, title: "title" }), + makeVisitResult(context, { uri: uri8.spec, title: "title" }), + ], + }); + + info("http://www.www matches nothing"); + searchString = "http://www.www"; + context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + uri: `${searchString}/`, + fallbackTitle: `${searchString}/`, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerAliasEngines.js b/browser/components/urlbar/tests/unit/test_providerAliasEngines.js new file mode 100644 index 0000000000..14ba368f0d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerAliasEngines.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests search engine aliases. See + * browser/components/urlbar/tests/browser/browser_tokenAlias.js for tests of + * the token alias list (i.e. showing all aliased engines on a "@" query). + */ + +testEngine_setup(); + +// Basic test that uses two engines, a GET engine and a POST engine, neither +// providing search suggestions. +add_task(async function basicGetAndPost() { + await SearchTestUtils.installSearchExtension({ + name: "AliasedGETMozSearch", + keyword: "get", + search_url: "https://s.example.com/search", + }); + await SearchTestUtils.installSearchExtension({ + name: "AliasedPOSTMozSearch", + keyword: "post", + search_url: "https://s.example.com/search", + search_url_post_params: "q={searchTerms}", + }); + + for (let alias of ["get", "post"]) { + let context = createContext(alias, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + context = createContext(`${alias} `, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} fire`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "fire", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} mozilla`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "mozilla", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} MoZiLlA`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "MoZiLlA", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + + context = createContext(`${alias} mozzarella mozilla`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: `Aliased${alias.toUpperCase()}MozSearch`, + query: "mozzarella mozilla", + alias, + heuristic: true, + providerName: "AliasEngines", + }), + ], + }); + } + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js b/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js new file mode 100644 index 0000000000..101f6fb21d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js @@ -0,0 +1,691 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that visit-url and search engine heuristic results are returned by + * UrlbarProviderHeuristicFallback. + */ + +const QUICKACTIONS_PREF = "browser.urlbar.suggest.quickactions"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; + +// We make sure that restriction tokens and search terms are correctly +// recognized when they are separated by each of these different types of spaces +// and combinations of spaces. U+3000 is the ideographic space in CJK and is +// commonly used by CJK speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +testEngine_setup(); + +add_task(async function setup() { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref(QUICKACTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref("keyword.enabled"); + }); + Services.prefs.setBoolPref(QUICKACTIONS_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); +}); + +add_task(async function () { + info("visit url, no protocol"); + let query = "mozilla.org"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, no protocol but with 2 dots"); + query = "www.mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, no protocol, e-mail like"); + query = "a@b.com"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("visit url, with protocol but with 2 dots"); + query = "https://www.mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + // info("visit url, with protocol but with 3 dots"); + query = "https://www.mozilla.org.tw"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, with protocol"); + query = "https://mozilla.org"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, about: protocol (no host)"); + query = "about:nonexistent"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("visit url, with non-standard whitespace"); + query = "https://mozilla.org"; + context = createContext(`${query}\u2028`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + // This is distinct because of how we predict being able to url autofill via + // host lookups. + info("visit url, host matching visited host but not visited url"); + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://mozilla.org/wine/"), + title: "Mozilla Wine", + transition: PlacesUtils.history.TRANSITION_TYPED, + }, + ]); + query = "mozilla.org/rum"; + context = createContext(`${query}\u2028`, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://mozilla.org/", + heuristic: true, + }), + ], + }); + await PlacesUtils.history.clear(); + + // And hosts with no dot in them are special, due to requiring safelisting. + info("unknown host"); + query = "firefox"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("string with known host"); + query = "firefox/get"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.firefox", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.firefox"); + }); + + info("known host"); + query = "firefox"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info("url with known host"); + query = "firefox/get"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://firefox/", + heuristic: true, + }), + ], + }); + + info("visit url, host matching visited host but not visited url, known host"); + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.mozilla", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.mozilla"); + }); + query = "mozilla/rum"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}`, + fallbackTitle: `http://${query}`, + iconUri: "page-icon:http://mozilla/", + heuristic: true, + }), + ], + }); + + // ipv4 and ipv6 literal addresses should offer to visit. + info("visit url, ipv4 literal"); + query = "127.0.0.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, ipv6 literal"); + query = "[2001:db8::1]"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + // Setting keyword.enabled to false should always try to visit. + let keywordEnabled = Services.prefs.getBoolPref("keyword.enabled"); + Services.prefs.setBoolPref("keyword.enabled", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("keyword.enabled"); + }); + info("visit url, keyword.enabled = false"); + query = "bacon"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("visit two word query, keyword.enabled = false"); + query = "bacon lovers"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("Forced search through a restriction token, keyword.enabled = false"); + query = "?bacon"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + query: "bacon", + }), + ], + }); + + Services.prefs.setBoolPref("keyword.enabled", true); + info("visit two word query, keyword.enabled = true"); + query = "bacon lovers"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref("keyword.enabled", keywordEnabled); + + info("visit url, scheme+host"); + query = "http://example"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, scheme+host"); + query = "ftp://example"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `${query}/`, + fallbackTitle: `${query}/`, + heuristic: true, + }), + ], + }); + + info("visit url, host+port"); + query = "example:8080"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + ], + }); + + info("numerical operations that look like urls should search"); + query = "123/12"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("numerical operations that look like urls should search"); + query = "123.12/12.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + query = "resource:///modules"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("access resource://app/modules"); + query = "resource://app/modules"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: query, + fallbackTitle: query, + heuristic: true, + }), + ], + }); + + info("protocol with an extra slash"); + query = "http:///"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("change default engine"); + let originalTestEngine = Services.search.getEngineByName( + SUGGESTIONS_ENGINE_NAME + ); + await SearchTestUtils.installSearchExtension({ + name: "AliasEngine", + keyword: "alias", + }); + let engine2 = Services.search.getEngineByName("AliasEngine"); + Assert.notEqual( + Services.search.defaultEngine, + engine2, + "New engine shouldn't be the current engine yet" + ); + await Services.search.setDefault( + engine2, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + query = "toronto"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "AliasEngine", + heuristic: true, + }), + ], + }); + await Services.search.setDefault( + originalTestEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + info( + "Leading search-mode restriction tokens are removed from the search result." + ); + for (let token of UrlbarTokenizer.SEARCH_MODE_RESTRICT) { + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query.substring(1).trimStart(); + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + let payload = { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: expectedQuery, + alias: token, + }; + if (token == UrlbarTokenizer.RESTRICT.SEARCH) { + payload.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + payload.engineName = SUGGESTIONS_ENGINE_NAME; + } + await check_results({ + context, + matches: [makeSearchResult(context, payload)], + }); + } + } + + info( + "Leading search-mode restriction tokens are removed from the search result with keyword.enabled = false." + ); + Services.prefs.setBoolPref("keyword.enabled", false); + for (let token of UrlbarTokenizer.SEARCH_MODE_RESTRICT) { + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query.substring(1).trimStart(); + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + let payload = { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: expectedQuery, + alias: token, + }; + if (token == UrlbarTokenizer.RESTRICT.SEARCH) { + payload.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + payload.engineName = SUGGESTIONS_ENGINE_NAME; + } + await check_results({ + context, + matches: [makeSearchResult(context, payload)], + }); + } + } + Services.prefs.clearUserPref("keyword.enabled"); + + info( + "Leading non-search-mode restriction tokens are not removed from the search result." + ); + for (let token of Object.values(UrlbarTokenizer.RESTRICT)) { + if (UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(token)) { + continue; + } + for (let spaces of TEST_SPACES) { + query = token + spaces + "query"; + info("Testing: " + JSON.stringify({ query, spaces: codePoints(spaces) })); + let expectedQuery = query; + context = createContext(query, { isPrivate: false }); + info(`Searching for "${query}", expecting "${expectedQuery}"`); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: expectedQuery, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + } + } + + info( + "Test the format inputed is user@host, and the host is in domainwhitelist" + ); + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.test-host", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.domainwhitelist.test-host"); + }); + + query = "any@test-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${query}/`, + fallbackTitle: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info( + "Test the format inputed is user@host, but the host is not in domainwhitelist" + ); + query = "any@not-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); + + info( + "Test if the format of user:pass@host is handled as visit even if the host is not in domainwhitelist" + ); + query = "user:pass@not-host"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user:pass@not-host/", + fallbackTitle: "http://user:pass@not-host/", + heuristic: true, + }), + ], + }); + + info("Test if the format of user@ipaddress is handled as visit"); + query = "user@192.168.0.1"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user@192.168.0.1/", + fallbackTitle: "http://user@192.168.0.1/", + heuristic: true, + }), + makeSearchResult(context, { + heuristic: false, + query, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ], + }); +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js b/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js new file mode 100644 index 0000000000..6f26fee7cb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerHistoryUrlHeuristic.js @@ -0,0 +1,197 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for the behavior of UrlbarProviderHistoryUrlHeuristic. + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); +}); + +add_task(async function test_basic() { + await PlacesTestUtils.addVisits([ + { uri: "https://example.com/", title: "Example COM" }, + ]); + + const testCases = [ + { + input: "https://example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "https://www.example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "http://example.com/", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + providerName: "Places", + }), + ], + }, + { + input: "example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + makeVisitResult(context, { + uri: "https://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + providerName: "Places", + }), + ], + }, + { + input: "www.example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://www.example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + { + input: "htp:example.com", + expected: context => [ + makeVisitResult(context, { + uri: "http://example.com/", + title: "Example COM", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HistoryUrlHeuristic", + }), + ], + }, + ]; + + for (const { input, expected } of testCases) { + info(`Test with "${input}"`); + const context = createContext(input, { isPrivate: false }); + await check_results({ + context, + matches: expected(context), + }); + } + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_null_title() { + await PlacesTestUtils.addVisits([{ uri: "https://example.com/", title: "" }]); + + const context = createContext("https://example.com/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://example.com/", + fallbackTitle: "https://example.com/", + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_over_max_length_text() { + let uri = "https://example.com/"; + for (; uri.length < UrlbarUtils.MAX_TEXT_LENGTH; ) { + uri += "0123456789"; + } + + await PlacesTestUtils.addVisits([{ uri, title: "Example MAX" }]); + + const context = createContext(uri, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri, + fallbackTitle: uri, + iconUri: "page-icon:https://example.com/", + heuristic: true, + providerName: "HeuristicFallback", + }), + ], + }); + + await PlacesUtils.history.clear(); +}); + +add_task(async function test_unsupported_protocol() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "about:robots", + title: "Robots!", + }); + + const context = createContext("about:robots", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "about:robots", + fallbackTitle: "about:robots", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeBookmarkResult(context, { + uri: "about:robots", + title: "Robots!", + }), + makeVisitResult(context, { + uri: "about:robots", + title: "about:robots", + tags: null, + providerName: "AboutPages", + }), + ], + }); + + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerKeywords.js b/browser/components/urlbar/tests/unit/test_providerKeywords.js new file mode 100644 index 0000000000..3be5fe30c0 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerKeywords.js @@ -0,0 +1,360 @@ +/* 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 bug 392143 that puts keyword results into the autocomplete. Makes + * sure that multiple parameter queries get spaces converted to +, + converted + * to %2B, non-ascii become escaped, and pages in history that match the + * keyword uses the page's title. + * + * Also test for bug 249468 by making sure multiple keyword bookmarks with the + * same keyword appear in the list. + */ + +testEngine_setup(); + +add_task(async function test_keyword_search() { + let uri1 = "http://abc/?search=%s"; + let uri2 = "http://abc/?search=ThisPageIsInHistory"; + let uri3 = "http://abc/?search=%s&raw=%S"; + let uri4 = "http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1"; + let uri5 = "http://def/?search=%s"; + let uri6 = "http://ghi/?search=%s&raw=%S"; + let uri7 = "http://somedomain.example/key2"; + await PlacesTestUtils.addVisits([ + { uri: uri1 }, + { uri: uri2 }, + { uri: uri3 }, + { uri: uri6 }, + { uri: uri7 }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "Keyword", + keyword: "key", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "Post", + keyword: "post", + postData: "post_search=%s", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "Encoded", + keyword: "encoded", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "Charset", + keyword: "charset", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Noparam", + keyword: "noparam", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "Noparam-Post", + keyword: "post_noparam", + postData: "noparam=1", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri5, + title: "Keyword", + keyword: "key2", + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri6, + title: "Charset-history", + keyword: "charset_history", + }); + + await PlacesUtils.history.update({ + url: uri6, + annotations: new Map([[PlacesUtils.CHARSET_ANNO, "ISO-8859-1"]]), + }); + + info("Plain keyword query"); + let context = createContext("key term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=term", + keyword: "key", + title: "abc: term", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Plain keyword UC"); + context = createContext("key TERM", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=TERM", + keyword: "key", + title: "abc: TERM", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Multi-word keyword query"); + context = createContext("key multi word", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=multi%20word", + keyword: "key", + title: "abc: multi word", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword query with +"); + context = createContext("key blocking+", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=blocking%2B", + keyword: "key", + title: "abc: blocking+", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Unescaped term in query"); + // ... but note that we call encodeURIComponent() on the query string when we + // build the URL, so the expected result will have the ユニコード substring + // encoded in the URL. + context = createContext("key ユニコード", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=" + encodeURIComponent("ユニコード"), + keyword: "key", + title: "abc: ユニコード", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword that happens to match a page"); + context = createContext("key ThisPageIsInHistory", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=ThisPageIsInHistory", + keyword: "key", + title: "abc: ThisPageIsInHistory", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Keyword with partial page match"); + context = createContext("key ThisPage", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=ThisPage", + keyword: "key", + title: "abc: ThisPage", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + // Only the most recent bookmark for the URL: + makeBookmarkResult(context, { + uri: "http://abc/?search=ThisPageIsInHistory", + title: "Noparam-Post", + }), + ], + }); + + // For the keyword with no query terms (with or without space after), the + // domain is different from the other tests because otherwise all the other + // test bookmarks and history entries would be matches. + info("Keyword without query (without space)"); + context = createContext("key2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://def/?search=", + fallbackTitle: "http://def/?search=", + keyword: "key2", + iconUri: "page-icon:http://def/?search=%s", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri5, + title: "Keyword", + }), + ], + }); + + info("Keyword without query (with space)"); + context = createContext("key2 ", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://def/?search=", + fallbackTitle: "http://def/?search=", + keyword: "key2", + iconUri: "page-icon:http://def/?search=%s", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri5, + title: "Keyword", + }), + ], + }); + + info("POST Keyword"); + context = createContext("post foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=foo", + keyword: "post", + title: "abc: foo", + postData: "post_search=foo", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("escaping with default UTF-8 charset"); + context = createContext("encoded foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=fo%C3%A9&raw=foé", + keyword: "encoded", + title: "abc: foé", + iconUri: "page-icon:http://abc/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("escaping with forced ISO-8859-1 charset"); + context = createContext("charset foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=fo%E9&raw=foé", + keyword: "charset", + title: "abc: foé", + iconUri: "page-icon:http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1", + heuristic: true, + }), + ], + }); + + info("escaping with ISO-8859-1 charset annotated in history"); + context = createContext("charset_history foé", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://ghi/?search=fo%E9&raw=foé", + keyword: "charset_history", + title: "ghi: foé", + iconUri: "page-icon:http://ghi/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("Bug 359809: escaping +, / and @ with default UTF-8 charset"); + context = createContext("encoded +/@", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=%2B%2F%40&raw=+/@", + keyword: "encoded", + title: "abc: +/@", + iconUri: "page-icon:http://abc/?search=%s&raw=%S", + heuristic: true, + }), + ], + }); + + info("Bug 359809: escaping +, / and @ with forced ISO-8859-1 charset"); + context = createContext("charset +/@", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=%2B%2F%40&raw=+/@", + keyword: "charset", + title: "abc: +/@", + iconUri: "page-icon:http://abc/?search=%s&raw=%S&mozcharset=ISO-8859-1", + heuristic: true, + }), + ], + }); + + info("Bug 1228111 - Keyword with a space in front"); + context = createContext(" key test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeKeywordSearchResult(context, { + uri: "http://abc/?search=test", + keyword: "key", + title: "abc: test", + iconUri: "page-icon:http://abc/?search=%s", + heuristic: true, + }), + ], + }); + + info("Bug 1481319 - Keyword with a prefix in front"); + context = createContext("http://key2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://key2/", + fallbackTitle: "http://key2/", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: uri7, + title: "test visit for http://somedomain.example/key2", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerOmnibox.js b/browser/components/urlbar/tests/unit/test_providerOmnibox.js new file mode 100644 index 0000000000..134158b5b1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerOmnibox.js @@ -0,0 +1,887 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +const { ExtensionSearchHandler } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionSearchHandler.sys.mjs" +); + +let controller = Cc["@mozilla.org/autocomplete/controller;1"].getService( + Ci.nsIAutoCompleteController +); + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; + +async function cleanup() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +add_task(function setup() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + }); +}); + +add_task(async function test_correct_errors_are_thrown() { + let keyword = "foo"; + let anotherKeyword = "bar"; + let unregisteredKeyword = "baz"; + + // Register a keyword. + ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }); + + // Try registering the keyword again. + Assert.throws( + () => ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }), + /The keyword provided is already registered/ + ); + + // Register a different keyword. + ExtensionSearchHandler.registerKeyword(anotherKeyword, { emit: () => {} }); + + // Try calling handleSearch for an unregistered keyword. + let searchData = { + keyword: unregisteredKeyword, + text: `${unregisteredKeyword} `, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The keyword provided is not registered/ + ); + + // Try calling handleSearch without a callback. + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData), + /The keyword provided is not registered/ + ); + + // Try getting the description for a keyword which isn't registered. + Assert.throws( + () => ExtensionSearchHandler.getDescription(unregisteredKeyword), + /The keyword provided is not registered/ + ); + + // Try setting the default suggestion for a keyword which isn't registered. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion( + unregisteredKeyword, + "suggestion" + ), + /The keyword provided is not registered/ + ); + + // Try calling handleInputCancelled when there is no active input session. + Assert.throws( + () => ExtensionSearchHandler.handleInputCancelled(), + /There is no active input session/ + ); + + // Try calling handleInputEntered when there is no active input session. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ), + /There is no active input session/ + ); + + // Start a session by calling handleSearch with the registered keyword. + searchData = { + keyword, + text: `${keyword} test`, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + + // Try providing suggestions for an unregistered keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 0, []), + /The keyword provided is not registered/ + ); + + // Try providing suggestions for an inactive keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(anotherKeyword, 0, []), + /The keyword provided is not apart of an active input session/ + ); + + // Try calling handleSearch for an inactive keyword. + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword} `, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /A different input session is already ongoing/ + ); + + // Try calling addSuggestions with an old callback ID. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 0, []), + /The callback is no longer active for the keyword provided/ + ); + + // Add suggestions with a valid callback ID. + ExtensionSearchHandler.addSuggestions(keyword, 1, []); + + // Add suggestions again with a valid callback ID. + ExtensionSearchHandler.addSuggestions(keyword, 1, []); + + // Try calling addSuggestions with a future callback ID. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 2, []), + /The callback is no longer active for the keyword provided/ + ); + + // End the input session by calling handleInputCancelled. + ExtensionSearchHandler.handleInputCancelled(); + + // Try calling handleInputCancelled after the session has ended. + Assert.throws( + () => ExtensionSearchHandler.handleInputCancelled(), + /There is no active input sessio/ + ); + + // Try calling handleSearch that doesn't have a space after the keyword. + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword}`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The text provided must start with/ + ); + + // Try calling handleSearch with text starting with the wrong keyword. + searchData = { + keyword: anotherKeyword, + text: `${keyword} test`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The text provided must start with/ + ); + + // Start a new session by calling handleSearch with a different keyword + searchData = { + keyword: anotherKeyword, + text: `${anotherKeyword} test`, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + + // Try adding suggestions again with the same callback ID now that the input session has ended. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 1, []), + /The keyword provided is not apart of an active input session/ + ); + + // Add suggestions with a valid callback ID. + ExtensionSearchHandler.addSuggestions(anotherKeyword, 2, []); + + // Try adding suggestions with a valid callback ID but a different keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(keyword, 2, []), + /The keyword provided is not apart of an active input session/ + ); + + // Try adding suggestions with a valid callback ID but an unregistered keyword. + Assert.throws( + () => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 2, []), + /The keyword provided is not registered/ + ); + + // Set the default suggestion. + ExtensionSearchHandler.setDefaultSuggestion(anotherKeyword, { + description: "test result", + }); + + // Try ending the session using handleInputEntered with a different keyword. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + keyword, + `${keyword} test`, + "tab" + ), + /A different input session is already ongoing/ + ); + + // Try calling handleInputEntered with invalid text. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered(anotherKeyword, ` test`, "tab"), + /The text provided must start with/ + ); + + // Try calling handleInputEntered with an invalid disposition. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "invalid" + ), + /Invalid "where" argument/ + ); + + // End the session by calling handleInputEntered. + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ); + + // Try calling handleInputEntered after the session has ended. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} test`, + "tab" + ), + /There is no active input session/ + ); + + // Unregister the keyword. + ExtensionSearchHandler.unregisterKeyword(keyword); + + // Try setting the default suggestion for the unregistered keyword. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "test", + }), + /The keyword provided is not registered/ + ); + + // Try handling a search with the unregistered keyword. + searchData = { + keyword, + text: `${keyword} test`, + }; + Assert.throws( + () => ExtensionSearchHandler.handleSearch(searchData, () => {}), + /The keyword provided is not registered/ + ); + + // Try unregistering the keyword again. + Assert.throws( + () => ExtensionSearchHandler.unregisterKeyword(keyword), + /The keyword provided is not registered/ + ); + + // Unregister the other keyword. + ExtensionSearchHandler.unregisterKeyword(anotherKeyword); + + // Try unregistering the word which was never registered. + Assert.throws( + () => ExtensionSearchHandler.unregisterKeyword(unregisteredKeyword), + /The keyword provided is not registered/ + ); + + // Try setting the default suggestion for a word that was never registered. + Assert.throws( + () => + ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, { + description: "test", + }), + /The keyword provided is not registered/ + ); + + await cleanup(); +}); + +add_task(async function test_extension_private_browsing() { + let events = []; + let mockExtension = { + emit: message => events.push(message), + privateBrowsingAllowed: false, + }; + + let keyword = "foo"; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let searchData = { + keyword, + text: `${keyword} test`, + inPrivateWindow: true, + }; + let result = await ExtensionSearchHandler.handleSearch(searchData); + Assert.equal(result, false, "unable to handle search for private window"); + + // Try calling handleInputEntered after the session has ended. + Assert.throws( + () => + ExtensionSearchHandler.handleInputEntered( + keyword, + `${keyword} test`, + "tab" + ), + /There is no active input session/ + ); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_extension_private_browsing_allowed() { + let extensionName = "Foo Bar"; + let mockExtension = { + name: extensionName, + emit: (message, text, id) => { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "foobar", description: "second suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + privateBrowsingAllowed: true, + }; + + let keyword = "foo"; + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let query = `${keyword} foo`; + let context = createContext(query, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: query, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foobar`, + description: "second suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_correct_events_are_emitted() { + let events = []; + function checkEvents(expectedEvents) { + Assert.equal( + events.length, + expectedEvents.length, + "The correct number of events fired" + ); + expectedEvents.forEach((e, i) => + Assert.equal(e, events[i], `Expected "${e}" event to fire`) + ); + events = []; + } + + let mockExtension = { emit: message => events.push(message) }; + + let keyword = "foo"; + let anotherKeyword = "bar"; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + ExtensionSearchHandler.registerKeyword(anotherKeyword, mockExtension); + + let searchData = { + keyword, + text: `${keyword} `, + }; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED]); + + searchData.text = `${keyword} f`; + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CHANGED]); + + ExtensionSearchHandler.handleInputEntered(keyword, searchData.text, "tab"); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.handleSearch(searchData, () => {}); + checkEvents([ + ExtensionSearchHandler.MSG_INPUT_STARTED, + ExtensionSearchHandler.MSG_INPUT_CHANGED, + ]); + + ExtensionSearchHandler.handleInputCancelled(); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CANCELLED]); + + ExtensionSearchHandler.handleSearch( + { + keyword: anotherKeyword, + text: `${anotherKeyword} baz`, + }, + () => {} + ); + checkEvents([ + ExtensionSearchHandler.MSG_INPUT_STARTED, + ExtensionSearchHandler.MSG_INPUT_CHANGED, + ]); + + ExtensionSearchHandler.handleInputEntered( + anotherKeyword, + `${anotherKeyword} baz`, + "tab" + ); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.unregisterKeyword(keyword); +}); + +add_task(async function test_removes_suggestion_if_its_content_is_typed_in() { + let keyword = "test"; + let extensionName = "Foo Bar"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + let query = `${keyword} unmatched`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} unmatched`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} foo`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} foo`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} bar`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} bar`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + ], + }); + + query = `${keyword} baz`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} baz`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_extension_results_should_come_first() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let uri = Services.io.newURI(`http://a.com/b`); + await PlacesTestUtils.addVisits([{ uri, title: `${keyword} -` }]); + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch( + { keyword, text: `${keyword} ` }, + () => {} + ); + + let query = `${keyword} -`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} -`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + makeVisitResult(context, { + uri: `http://a.com/b`, + title: `${keyword} -`, + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_setting_the_default_suggestion() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, []); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "hello world", + }); + + let query = `${keyword} search query`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: "hello world", + content: query, + }), + ], + }); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "foo bar", + }); + + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + searchParam: "enable-actions", + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: "foo bar", + content: query, + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function test_maximum_number_of_suggestions_is_enforced() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "a", description: "first suggestion" }, + { content: "b", description: "second suggestion" }, + { content: "c", description: "third suggestion" }, + { content: "d", description: "fourth suggestion" }, + { content: "e", description: "fifth suggestion" }, + { content: "f", description: "sixth suggestion" }, + { content: "g", description: "seventh suggestion" }, + { content: "h", description: "eigth suggestion" }, + { content: "i", description: "ninth suggestion" }, + { content: "j", description: "tenth suggestion" }, + { content: "k", description: "eleventh suggestion" }, + { content: "l", description: "twelfth suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch( + { keyword, text: `${keyword} ` }, + () => {} + ); + + let query = `${keyword} #`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} #`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} a`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} b`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} c`, + description: "third suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} d`, + description: "fourth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} e`, + description: "fifth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} f`, + description: "sixth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} g`, + description: "seventh suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} h`, + description: "eigth suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} i`, + description: "ninth suggestion", + }), + ], + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + await cleanup(); +}); + +add_task(async function conflicting_alias() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + let engine = await addTestSuggestionsEngine(); + let keyword = "test"; + engine.alias = keyword; + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + { content: "foo", description: "first suggestion" }, + { content: "bar", description: "second suggestion" }, + { content: "baz", description: "third suggestion" }, + ]); + // The API doesn't have a way to notify when addition is complete. + do_timeout(1000, () => { + controller.stopSearch(); + }); + } + }, + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + let query = `${keyword} unmatched`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeOmniboxResult(context, { + heuristic: true, + keyword, + description: extensionName, + content: `${keyword} unmatched`, + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} foo`, + description: "first suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} bar`, + description: "second suggestion", + }), + makeOmniboxResult(context, { + keyword, + content: `${keyword} baz`, + description: "third suggestion", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched foo", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: SUGGESTIONS_ENGINE_NAME, + alias: keyword, + suggestion: "unmatched bar", + }), + ], + }); + + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + await cleanup(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerOpenTabs.js b/browser/components/urlbar/tests/unit/test_providerOpenTabs.js new file mode 100644 index 0000000000..6cdf632b42 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerOpenTabs.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_openTabs() { + const userContextId = 5; + const url = "http://foo.mozilla.org/"; + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId, false); + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId, false); + Assert.equal( + UrlbarProviderOpenTabs._openTabs.get(userContextId).length, + 2, + "Found all the expected tabs" + ); + UrlbarProviderOpenTabs.unregisterOpenTab(url, userContextId, false); + Assert.equal( + UrlbarProviderOpenTabs._openTabs.get(userContextId).length, + 1, + "Found all the expected tabs" + ); + + let context = createContext(); + let matchCount = 0; + let callback = function (provider, match) { + matchCount++; + Assert.ok( + provider instanceof UrlbarProviderOpenTabs, + "Got the expected provider" + ); + Assert.equal( + match.type, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + "Got the expected result type" + ); + Assert.equal(match.payload.url, url, "Got the expected url"); + Assert.equal(match.payload.title, undefined, "Got the expected title"); + }; + + let provider = new UrlbarProviderOpenTabs(); + await provider.startQuery(context, callback); + Assert.equal(matchCount, 1, "Found the expected number of matches"); + // Sanity check that this doesn't throw. + provider.cancelQuery(context); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces.js b/browser/components/urlbar/tests/unit/test_providerPlaces.js new file mode 100644 index 0000000000..c64f3345e1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a simple test to check the Places provider works, it is not +// intended to check all the edge cases, because that component is already +// covered by a good amount of tests. + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; + +add_task(async function test_places() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let engine = await addTestSuggestionsEngine(); + Services.search.defaultEngine = engine; + let oldCurrentEngine = Services.search.defaultEngine; + registerCleanupFunction(() => { + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + Services.search.defaultEngine = oldCurrentEngine; + }); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add entries from multiple sources. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI( + Services.io.newURI("https://bookmark.mozilla.org/"), + ["mozilla", "org", "ham", "moz", "bacon"] + ); + await PlacesTestUtils.addVisits([ + { uri: "https://history.mozilla.org/", title: "Test history" }, + { uri: "https://tab.mozilla.org/", title: "Test tab" }, + ]); + UrlbarProviderOpenTabs.registerOpenTab("https://tab.mozilla.org/", 0, false); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 6, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [ + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_TYPE.URL, + ], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [ + searchString, + searchString + " foo", + searchString + " bar", + "Test bookmark", + "Test tab", + "Test history", + ], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual( + context.results[3].payload.tags, + ["moz", "mozilla", "org"], + "Check tags" + ); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + UrlbarProviderOpenTabs.unregisterOpenTab( + "https://tab.mozilla.org/", + 0, + false + ); +}); + +add_task(async function test_bookmarkBehaviorDisabled_tagged() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Disable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add a tagged bookmark that's also visited. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + PlacesUtils.tagging.tagURI( + Services.io.newURI("https://bookmark.mozilla.org/"), + ["mozilla", "org", "ham", "moz", "bacon"] + ); + await PlacesTestUtils.addVisits("https://bookmark.mozilla.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark"], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual(context.results[1].payload.tags, [], "Check tags"); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_bookmarkBehaviorDisabled_untagged() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Disable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + + let controller = UrlbarTestUtils.newMockController(); + // Also check case insensitivity. + let searchString = "MoZ oRg"; + let context = createContext(searchString, { isPrivate: false }); + + // Add an *untagged* bookmark that's also visited. + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/", + title: "Test bookmark", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesTestUtils.addVisits("https://bookmark.mozilla.org/"); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark"], + context.results.map(m => m.title), + "Check match titles" + ); + + Assert.deepEqual(context.results[1].payload.tags, [], "Check tags"); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(async function test_diacritics() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Enable the bookmark behavior. + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + + let controller = UrlbarTestUtils.newMockController(); + let searchString = "agui"; + let context = createContext(searchString, { isPrivate: false }); + + await PlacesUtils.bookmarks.insert({ + url: "https://bookmark.mozilla.org/%C3%A3g%CC%83u%C4%A9", + title: "Test bookmark with accents in path", + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + await controller.startQuery(context); + + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + + Assert.deepEqual( + [UrlbarUtils.RESULT_TYPE.SEARCH, UrlbarUtils.RESULT_TYPE.URL], + context.results.map(m => m.type), + "Check result types" + ); + + Assert.deepEqual( + [searchString, "Test bookmark with accents in path"], + context.results.map(m => m.title), + "Check match titles" + ); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js b/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js new file mode 100644 index 0000000000..7533921fc6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces_duplicate_entries.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_duplicates() { + const TEST_URL = "https://history.mozilla.org/"; + await PlacesTestUtils.addVisits([ + { uri: TEST_URL, title: "Test history" }, + { uri: TEST_URL + "?#", title: "Test history" }, + { uri: TEST_URL + "#", title: "Test history" }, + ]); + + let controller = UrlbarTestUtils.newMockController(); + let searchString = "^Hist"; + let context = createContext(searchString, { isPrivate: false }); + await controller.startQuery(context); + + // The first result will be a search heuristic, which we don't care about for + // this test. + info( + "Results:\n" + + context.results.map(m => `${m.title} - ${m.payload.url}`).join("\n") + ); + Assert.equal( + context.results.length, + 2, + "Found the expected number of matches" + ); + Assert.equal( + context.results[1].type, + UrlbarUtils.RESULT_TYPE.URL, + "Should have a history result" + ); + Assert.equal( + context.results[1].payload.url, + TEST_URL + "#", + "Check result URL" + ); + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js b/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js new file mode 100644 index 0000000000..2cb5f5797a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPlaces_nonEnglish.js @@ -0,0 +1,43 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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 autocomplete for non-English URLs + +- add a visit for a page with a non-English URL +- search +- test number of matches (should be exactly one) + +*/ + +testEngine_setup(); + +add_task(async function test_autocomplete_non_english() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + let searchTerm = "ユニコード"; + let unescaped = "http://www.foobar.com/" + searchTerm + "/"; + let uri = Services.io.newURI(unescaped); + await PlacesTestUtils.addVisits(uri); + let context = createContext(searchTerm, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: `test visit for ${uri.spec}`, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerPreloaded.js b/browser/components/urlbar/tests/unit/test_providerPreloaded.js new file mode 100644 index 0000000000..0785e3afba --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerPreloaded.js @@ -0,0 +1,578 @@ +/** + * Test for bug 1211726 - preload list of top web sites for better + * autocompletion on empty profiles. + */ + +testEngine_setup(); + +const PREF_FEATURE_ENABLED = "browser.urlbar.usepreloadedtopurls.enabled"; +const PREF_FEATURE_EXPIRE_DAYS = + "browser.urlbar.usepreloadedtopurls.expire_days"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderPreloadedSites: + "resource:///modules/UrlbarProviderPreloadedSites.sys.mjs", +}); + +Cu.importGlobalProperties(["fetch"]); + +let yahoooURI = "https://yahooo.com/"; +let gooogleURI = "https://gooogle.com/"; + +UrlbarProviderPreloadedSites.populatePreloadedSiteStorage([ + [yahoooURI, "Yahooo"], + [gooogleURI, "Gooogle"], +]); + +async function assert_feature_works(condition) { + info("List Results do appear " + condition); + let context = createContext("ooo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: yahoooURI, + title: "Yahooo", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${yahoooURI}`, + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: gooogleURI, + title: "Gooogle", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${gooogleURI}`, + providerName: "PreloadedSites", + }), + ], + }); + + info("Autofill does appear " + condition); + context = createContext("gooo", { isPrivate: false }); + await check_results({ + context, + autofilled: "gooogle.com/", + completed: gooogleURI, + matches: [ + makeVisitResult(context, { + uri: gooogleURI, + fallbackTitle: gooogleURI.slice(0, -1), // Trim trailing slash. + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: `page-icon:${gooogleURI}`, + providerName: "PreloadedSites", + heuristic: true, + }), + ], + }); +} + +async function assert_feature_does_not_appear(condition) { + info("List Results don't appear " + condition); + let context = createContext("ooo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + }), + ], + }); + + info("Autofill doesn't appear " + condition); + context = createContext("gooo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + }), + ], + }); +} + +add_task(async function test_it_works() { + // Not expired but OFF + Services.prefs.setIntPref(PREF_FEATURE_EXPIRE_DAYS, 14); + Services.prefs.setBoolPref(PREF_FEATURE_ENABLED, false); + await assert_feature_does_not_appear("when OFF by prefs"); + + // Now turn it ON + Services.prefs.setBoolPref(PREF_FEATURE_ENABLED, true); + await assert_feature_works("when ON by prefs"); + + // And expire + Services.prefs.setIntPref(PREF_FEATURE_EXPIRE_DAYS, 0); + await assert_feature_does_not_appear("when expired"); + + await cleanupPlaces(); +}); + +add_task(async function test_sorting_against_bookmark() { + let boookmarkURI = "https://boookmark.com/"; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: boookmarkURI, + title: "Boookmark", + }); + + Services.prefs.setBoolPref(PREF_FEATURE_ENABLED, true); + Services.prefs.setIntPref(PREF_FEATURE_EXPIRE_DAYS, 14); + + info("Preloaded Top Sites are placed lower than Bookmarks"); + let context = createContext("ooo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: boookmarkURI, + title: "Boookmark", + }), + makeVisitResult(context, { + uri: yahoooURI, + title: "Yahooo", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${yahoooURI}`, + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: gooogleURI, + title: "Gooogle", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${gooogleURI}`, + providerName: "PreloadedSites", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_sorting_against_history() { + let histoooryURI = "https://histooory.com/"; + await PlacesTestUtils.addVisits({ uri: histoooryURI, title: "Histooory" }); + + Services.prefs.setBoolPref(PREF_FEATURE_ENABLED, true); + Services.prefs.setIntPref(PREF_FEATURE_EXPIRE_DAYS, 14); + + info("Preloaded Top Sites are placed lower than History entries"); + let context = createContext("ooo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: histoooryURI, + title: "Histooory", + }), + makeVisitResult(context, { + uri: yahoooURI, + title: "Yahooo", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${yahoooURI}`, + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: gooogleURI, + title: "Gooogle", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: `page-icon:${gooogleURI}`, + providerName: "PreloadedSites", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_scheme_and_www() { + // Order is important to check sorting + let sites = [ + ["https://www.ooops-https-www.com/", "Ooops"], + ["https://ooops-https.com/", "Ooops"], + ["HTTP://ooops-HTTP.com/", "Ooops"], + ["HTTP://www.ooops-HTTP-www.com/", "Ooops"], + ["https://foo.com/", "Title with www"], + ["https://www.bar.com/", "Tile"], + ]; + + let titlesMap = new Map(sites); + + UrlbarProviderPreloadedSites.populatePreloadedSiteStorage(sites); + + // No matches when just typing the protocol. + let context = createContext("https://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("www.", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.ooops-http-www.com/", + title: titlesMap.get("HTTP://www.ooops-HTTP-www.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:http://www.ooops-http-www.com/", + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: "https://www.bar.com/", + title: titlesMap.get("https://www.bar.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:https://www.bar.com/", + providerName: "PreloadedSites", + }), + ], + }); + + context = createContext("http://www.", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://www.ooops-http-www.com/", + completed: "http://www.ooops-http-www.com/", + matches: [ + makeVisitResult(context, { + uri: "http://www.ooops-http-www.com/", + fallbackTitle: "www.ooops-http-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:http://www.ooops-http-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + ], + }); + + context = createContext("ftp://ooops", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "ftp://ooops/", + fallbackTitle: "ftp://ooops/", + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); + + context = createContext("ww", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.ooops-http-www.com/", + title: titlesMap.get("HTTP://www.ooops-HTTP-www.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:http://www.ooops-http-www.com/", + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: "https://foo.com/", + title: titlesMap.get("https://foo.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:https://foo.com/", + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: "https://www.bar.com/", + title: titlesMap.get("https://www.bar.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:https://www.bar.com/", + providerName: "PreloadedSites", + }), + ], + }); + + context = createContext("ooops", { isPrivate: false }); + await check_results({ + context, + autofilled: "ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://ooops-https.com/", + title: titlesMap.get("https://ooops-https.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:https://ooops-https.com/", + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: "http://ooops-http.com/", + title: titlesMap.get("HTTP://ooops-HTTP.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:http://ooops-http.com/", + providerName: "PreloadedSites", + }), + makeVisitResult(context, { + uri: "http://www.ooops-http-www.com/", + title: titlesMap.get("HTTP://www.ooops-HTTP-www.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:http://www.ooops-http-www.com/", + providerName: "PreloadedSites", + }), + ], + }); + + context = createContext("www.ooops", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.ooops-http-www.com/", + title: titlesMap.get("HTTP://www.ooops-HTTP-www.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:http://www.ooops-http-www.com/", + providerName: "PreloadedSites", + }), + ], + }); + + context = createContext("ooops-https-www", { isPrivate: false }); + await check_results({ + context, + autofilled: "ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + ], + }); + + context = createContext("www.ooops-https.", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.ooops-https./", + fallbackTitle: "http://www.ooops-https./", + providerName: "HeuristicFallback", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + providerName: "HeuristicFallback", + }), + ], + }); + + context = createContext("https://ooops", { isPrivate: false }); + await check_results({ + context, + autofilled: "https://ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://ooops-https.com/", + title: titlesMap.get("https://ooops-https.com/"), + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + tags: null, + iconUri: "page-icon:https://ooops-https.com/", + providerName: "PreloadedSites", + }), + ], + }); + + context = createContext("https://www.ooops", { isPrivate: false }); + await check_results({ + context, + autofilled: "https://www.ooops-https-www.com/", + completed: "https://www.ooops-https-www.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.ooops-https-www.com/", + fallbackTitle: "https://www.ooops-https-www.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: "page-icon:https://www.ooops-https-www.com/", + providerName: "PreloadedSites", + heuristic: true, + }), + ], + }); + + context = createContext("http://www.ooops-http.", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.ooops-http./", + fallbackTitle: "http://www.ooops-http./", + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); + + context = createContext("http://ooops-https", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://ooops-https/", + fallbackTitle: "http://ooops-https/", + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_data_file() { + let response = await fetch( + "chrome://browser/content/urlbar/preloaded-top-urls.json" + ); + + info("Source file is supplied and fetched OK"); + Assert.ok(response.ok); + + info("The JSON is parsed"); + let sites = await response.json(); + + // Add test site so this test doesn't depend on the contents of the data file. + sites.push(["https://www.example.com/", "Example"]); + + info("Storage is populated"); + UrlbarProviderPreloadedSites.populatePreloadedSiteStorage(sites); + + let lastSite = sites.pop(); + let uri = Services.io.newURI(lastSite[0]); + + info("Storage is populated from JSON correctly"); + let context = createContext(uri.host, { isPrivate: false }); + await check_results({ + context, + autofilled: uri.host + "/", + completed: uri.spec, + matches: [ + makeVisitResult(context, { + uri: uri.spec, + fallbackTitle: uri.spec.slice(0, -1), // Trim trailing slash. + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: `page-icon:${uri.spec}`, + providerName: "PreloadedSites", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_partial_scheme() { + // "tt" should not result in a match of "ttps://whatever.com/". + let testUrl = "http://www.ttt.com/"; + UrlbarProviderPreloadedSites.populatePreloadedSiteStorage([ + [testUrl, "Test"], + ]); + let context = createContext("tt", { isPrivate: false }); + await check_results({ + context, + autofilled: "ttt.com/", + completed: testUrl, + matches: [ + makeVisitResult(context, { + uri: testUrl, + fallbackTitle: "www.ttt.com", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + iconUri: `page-icon:${testUrl}`, + heuristic: true, + providerName: "PreloadedSites", + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerTabToSearch.js b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js new file mode 100644 index 0000000000..fe625f7bb9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js @@ -0,0 +1,535 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests UrlbarProviderTabToSearch. See also + * browser/components/urlbar/tests/browser/browser_tabToSearch.js + */ + +"use strict"; + +let testEngine; + +add_task(async function init() { + // Disable search suggestions for a less verbose test. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + // Disable tab-to-search onboarding results. Those are covered in + // browser/components/urlbar/tests/browser/browser_tabToSearch.js. + Services.prefs.setIntPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft", + 0 + ); + await SearchTestUtils.installSearchExtension({ name: "Test" }); + testEngine = await Services.search.getEngineByName("Test"); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + }); +}); + +// Tests that tab-to-search results appear when the engine's result domain is +// autofilled. +add_task(async function basic() { + await PlacesTestUtils.addVisits(["https://example.com/"]); + let context = createContext("examp", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + info("Repeat the search but with tab-to-search disabled through pref."); + Services.prefs.setBoolPref("browser.urlbar.suggest.engines", false); + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + + await cleanupPlaces(); +}); + +// Tests that tab-to-search results are shown when the typed string matches an +// engine domain even when there is no autofill. +add_task(async function noAutofill() { + // Note we are not adding any history visits. + let context = createContext("examp", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.iconURI?.spec, + heuristic: true, + providerName: "HeuristicFallback", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); +}); + +// Tests that tab-to-search results are not shown when the typed string matches +// an engine domain, but something else is being autofilled. +add_task(async function autofillDoesNotMatchEngine() { + await PlacesTestUtils.addVisits(["https://example.test.ca/"]); + let context = createContext("example", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.test.ca/", + completed: "https://example.test.ca/", + matches: [ + makeVisitResult(context, { + uri: "https://example.test.ca/", + title: "test visit for https://example.test.ca/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + + await cleanupPlaces(); +}); + +// Tests that www. is ignored for the purposes of matching autofill to +// tab-to-search. +add_task(async function ignoreWww() { + // The history result has www., the engine does not. + await PlacesTestUtils.addVisits(["https://www.example.com/"]); + let context = createContext("www.examp", { isPrivate: false }); + await check_results({ + context, + autofilled: "www.example.com/", + completed: "https://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://www.example.com/", + title: "test visit for https://www.example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + // The engine has www., the history result does not. + await PlacesTestUtils.addVisits(["https://foo.bar/"]); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestWww", + search_url: "https://www.foo.bar/", + }, + { skipUnload: true } + ); + let wwwTestEngine = Services.search.getEngineByName("TestWww"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.bar/", + completed: "https://foo.bar/", + matches: [ + makeVisitResult(context, { + uri: "https://foo.bar/", + title: "test visit for https://foo.bar/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + // Both the engine and the history result have www. + await PlacesTestUtils.addVisits(["https://www.foo.bar/"]); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.bar/", + completed: "https://www.foo.bar/", + matches: [ + makeVisitResult(context, { + uri: "https://www.foo.bar/", + title: "test visit for https://www.foo.bar/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + await extension.unload(); +}); + +// Tests that when a user's query causes autofill to replace one engine's domain +// with another, the correct tab-to-search results are shown. +add_task(async function conflictingEngines() { + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([ + "https://foobar.com/", + "https://foo.com/", + ]); + } + let extension1 = await SearchTestUtils.installSearchExtension( + { + name: "TestFooBar", + search_url: "https://foobar.com/", + }, + { skipUnload: true } + ); + let extension2 = await SearchTestUtils.installSearchExtension( + { + name: "TestFoo", + search_url: "https://foo.com/", + }, + { skipUnload: true } + ); + let fooBarTestEngine = Services.search.getEngineByName("TestFooBar"); + let fooTestEngine = Services.search.getEngineByName("TestFoo"); + + // Search for "foo", autofilling foo.com. Observe that the foo.com + // tab-to-search result is shown, even though the foobar.com engine was added + // first (and thus enginesForDomainPrefix puts it earlier in its returned + // array.) + let context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.com/", + completed: "https://foo.com/", + matches: [ + makeVisitResult(context, { + uri: "https://foo.com/", + title: "test visit for https://foo.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + makeVisitResult(context, { + uri: "https://foobar.com/", + title: "test visit for https://foobar.com/", + providerName: "Places", + }), + ], + }); + + // Search for "foob", autofilling foobar.com. Observe that the foo.com + // tab-to-search result is replaced with the foobar.com tab-to-search result. + context = createContext("foob", { isPrivate: false }); + await check_results({ + context, + autofilled: "foobar.com/", + completed: "https://foobar.com/", + matches: [ + makeVisitResult(context, { + uri: "https://foobar.com/", + title: "test visit for https://foobar.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooBarTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooBarTestEngine.searchUrlDomain + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + await cleanupPlaces(); + await extension1.unload(); + await extension2.unload(); +}); + +add_task(async function multipleEnginesForHostname() { + info( + "In case of multiple engines only one tab-to-search result should be returned" + ); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "TestMaps", + search_url: "https://example.com/maps/", + }, + { skipUnload: true } + ); + + let context = createContext("examp", { isPrivate: false }); + let maxResultCount = UrlbarPrefs.get("maxRichResults"); + + // Add enough visits to autofill example.com. + for (let i = 0; i < maxResultCount; i++) { + await PlacesTestUtils.addVisits("https://example.com/"); + } + + // Add enough visits to other URLs matching our query to fill up the list of + // results. + let otherVisitResults = []; + for (let i = 0; i < maxResultCount; i++) { + let url = "https://mochi.test:8888/example/" + i; + await PlacesTestUtils.addVisits(url); + otherVisitResults.unshift( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + } + + await check_results({ + context, + autofilled: "example.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + // There should be `maxResultCount` - 2 other visit results. If this fails + // because there are actually `maxResultCount` - 3 other results, then the + // muxer is improperly including both TabToSearch results in its + // calculation of the total available result span instead of only one, so + // one fewer visit result appears than expected. + ...otherVisitResults.slice(0, maxResultCount - 2), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_casing() { + info("Tab-to-search results appear also in case of different casing."); + await PlacesTestUtils.addVisits(["https://example.com/"]); + let context = createContext("eXAm", { isPrivate: false }); + await check_results({ + context, + autofilled: "eXAmple.com/", + completed: "https://example.com/", + matches: [ + makeVisitResult(context, { + uri: "https://example.com/", + title: "test visit for https://example.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(testEngine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_publicSuffix() { + info("Tab-to-search results appear also in case of partial host match."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "MyTest", + search_url: "https://test.mytest.it/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("MyTest"); + await PlacesTestUtils.addVisits(["https://test.mytest.it/"]); + let context = createContext("my", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.iconURI?.spec, + heuristic: true, + providerName: "HeuristicFallback", + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeVisitResult(context, { + uri: "https://test.mytest.it/", + title: "test visit for https://test.mytest.it/", + providerName: "Places", + }), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_publicSuffixIsHost() { + info("Tab-to-search results does not appear in case we autofill a suffix."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "SuffixTest", + search_url: "https://somesuffix.com.mx/", + }, + { skipUnload: true } + ); + + // The top level domain will be autofilled, not the full domain. + await PlacesTestUtils.addVisits(["https://com.mx/"]); + let context = createContext("co", { isPrivate: false }); + await check_results({ + context, + autofilled: "com.mx/", + completed: "https://com.mx/", + matches: [ + makeVisitResult(context, { + uri: "https://com.mx/", + title: "test visit for https://com.mx/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + await cleanupPlaces(); + await extension.unload(); +}); + +add_task(async function test_disabledEngine() { + info("Tab-to-search results does not appear for a Pref-disabled engine."); + let extension = await SearchTestUtils.installSearchExtension( + { + name: "Disabled", + search_url: "https://disabled.com/", + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Disabled"); + await PlacesTestUtils.addVisits(["https://disabled.com/"]); + let context = createContext("dis", { isPrivate: false }); + + info("Sanity check that the engine would appear."); + await check_results({ + context, + autofilled: "disabled.com/", + completed: "https://disabled.com/", + matches: [ + makeVisitResult(context, { + uri: "https://disabled.com/", + title: "test visit for https://disabled.com/", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + info("Now disable the engine."); + Services.prefs.setCharPref("browser.search.hiddenOneOffs", engine.name); + await check_results({ + context, + autofilled: "disabled.com/", + completed: "https://disabled.com/", + matches: [ + makeVisitResult(context, { + uri: "https://disabled.com/", + title: "test visit for https://disabled.com/", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + + await cleanupPlaces(); + await extension.unload(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js b/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js new file mode 100644 index 0000000000..d2391c0f43 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +// Search engine origins are autofilled normally when they get over the +// threshold, though certain origins redirect to localized subdomains, that +// the user is unlikely to type, for example wikipedia.org => en.wikipedia.org. +// We should get a tab to search result also for these cases, where a normal +// autofill wouldn't happen. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", +}); + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + // Disable tab-to-search onboarding results. + Services.prefs.setIntPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft", + 0 + ); + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); + }); +}); + +add_task(async function test() { + let url = "https://en.example.com/"; + await SearchTestUtils.installSearchExtension( + { + name: "TestEngine", + search_url: url, + }, + { setAsDefault: true } + ); + + // Make sure the engine domain would be autofilled. + await PlacesUtils.bookmarks.insert({ + url, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + }); + + info("Test matching cases"); + + for (let searchStr of ["ex", "example.c"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + providerName: "HeuristicFallback", + heuristic: true, + }), + makeSearchResult(context, { + engineName: "TestEngine", + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: "en.example.", + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeBookmarkResult(context, { + uri: url, + title: "bookmark", + }), + ], + }); + } + + info("Test a www engine"); + let url2 = "https://www.it.mochi.com/"; + await SearchTestUtils.installSearchExtension({ + name: "TestEngine2", + search_url: url2, + }); + + let engine2 = Services.search.getEngineByName("TestEngine2"); + // Make sure the engine domain would be autofilled. + await PlacesUtils.bookmarks.insert({ + url: url2, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "bookmark", + }); + + for (let searchStr of ["mo", "mochi.c"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + providerName: "HeuristicFallback", + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine2.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: "www.it.mochi.", + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeBookmarkResult(context, { + uri: url2, + title: "bookmark", + }), + ], + }); + } + + info("Test non-matching cases"); + + for (let searchStr of ["www.en", "www.ex", "https://ex"]) { + info("Searching for " + searchStr); + let context = createContext(searchStr, { isPrivate: false }); + // We don't want to generate all the possible results here, just check + // the heuristic result is not autofill. + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.ok(context.results[0].heuristic, "Check heuristic result"); + Assert.notEqual(context.results[0].providerName, "Autofill"); + } + + info("Tab-to-search is not shown when an unrelated site is autofilled."); + let wikiUrl = "https://wikipedia.org/"; + await SearchTestUtils.installSearchExtension({ + name: "FakeWikipedia", + search_url: url, + }); + let wikiEngine = Services.search.getEngineByName("TestEngine"); + + // Make sure that wikiUrl will pass getTopHostOverThreshold. + await PlacesUtils.bookmarks.insert({ + url: wikiUrl, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Wikipedia", + }); + + // Make sure an unrelated www site is autofilled. + let wwwUrl = "https://www.example.com"; + await PlacesUtils.bookmarks.insert({ + url: wwwUrl, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "Example", + }); + + let searchStr = "w"; + let context = createContext(searchStr, { + isPrivate: false, + sources: [UrlbarUtils.RESULT_SOURCE.BOOKMARKS], + }); + let host = await UrlbarProviderAutofill.getTopHostOverThreshold(context, [ + wikiEngine.searchUrlDomain, + ]); + Assert.equal( + host, + wikiEngine.searchUrlDomain, + "The search satisfies the autofill threshold requirement." + ); + await check_results({ + context, + autofilled: "www.example.com/", + completed: "https://www.example.com/", + matches: [ + makeVisitResult(context, { + uri: `${wwwUrl}/`, + title: "Example", + heuristic: true, + providerName: "Autofill", + }), + // Note that tab-to-search is not shown. + makeBookmarkResult(context, { + uri: wikiUrl, + title: "Wikipedia", + }), + makeBookmarkResult(context, { + uri: url2, + title: "bookmark", + }), + ], + }); + + info("Restricting to history should not autofill our bookmark"); + context = createContext("ex", { + isPrivate: false, + sources: [UrlbarUtils.RESULT_SOURCE.HISTORY], + }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.ok(context.results[0].heuristic, "Check heuristic result"); + Assert.notEqual(context.results[0].providerName, "Autofill"); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_providersManager.js b/browser/components/urlbar/tests/unit/test_providersManager.js new file mode 100644 index 0000000000..8446ed0675 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_providers() { + Assert.throws( + () => UrlbarProvidersManager.registerProvider(), + /invalid provider/, + "Should throw with no arguments" + ); + Assert.throws( + () => UrlbarProvidersManager.registerProvider({}), + /invalid provider/, + "Should throw with empty object" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "", + }), + /invalid provider/, + "Should throw with empty name" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "test", + startQuery: "no", + }), + /invalid provider/, + "Should throw with invalid startQuery" + ); + Assert.throws( + () => + UrlbarProvidersManager.registerProvider({ + name: "test", + startQuery: () => {}, + cancelQuery: "no", + }), + /invalid provider/, + "Should throw with invalid cancelQuery" + ); + + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + + let provider = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + let resultsPromise = promiseControllerNotification( + controller, + "onQueryResults" + ); + + await UrlbarProvidersManager.startQuery(context, controller); + // Sanity check that this doesn't throw. It should be a no-op since we await + // for startQuery. + UrlbarProvidersManager.cancelQuery(context); + + let params = await resultsPromise; + Assert.deepEqual(params[0].results, [match]); +}); + +add_task(async function test_criticalSection() { + // Just a sanity check, this shouldn't throw. + await UrlbarProvidersManager.runInCriticalSection(async () => { + let db = await PlacesUtils.promiseLargeCacheDBConnection(); + await db.execute(`PRAGMA page_cache`); + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_providersManager_filtering.js b/browser/components/urlbar/tests/unit/test_providersManager_filtering.js new file mode 100644 index 0000000000..a631bfa204 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager_filtering.js @@ -0,0 +1,407 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_filtering_disable_only_source() { + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + let provider = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("Disable the only available source, should get no matches"); + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + let promise = Promise.race([ + promiseControllerNotification(controller, "onQueryResults", false), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context); + await promise; + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + UrlbarProvidersManager.unregisterProvider({ name: provider.name }); +}); + +add_task(async function test_filtering_disable_one_source() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("Disable one of the sources, should get a single match"); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + let promise = Promise.all([ + promiseControllerNotification(controller, "onQueryResults"), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, matches.slice(0, 1)); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filtering_restriction_token() { + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(matches); + let context = createContext(`foo ${UrlbarTokenizer.RESTRICT.OPENPAGE}`, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Use a restriction character, should get a single match"); + let promise = Promise.all([ + promiseControllerNotification(controller, "onQueryResults"), + promiseControllerNotification(controller, "onQueryFinished"), + ]); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, matches.slice(0, 1)); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_javascript() { + let match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ); + let jsMatch = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "javascript:foo" } + ); + let provider = registerBasicTestProvider([match, jsMatch]); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + info("By default javascript should be filtered out"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [match]); + + info("Except when the user explicitly starts the search with javascript:"); + context = createContext(`javascript: ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + providers: [provider.name], + }); + promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [jsMatch]); + + info("Disable javascript filtering"); + Services.prefs.setBoolPref("browser.urlbar.filter.javascript", false); + context = createContext(undefined, { providers: [provider.name] }); + promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results, [match, jsMatch]); + Services.prefs.clearUserPref("browser.urlbar.filter.javascript"); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_isActive() { + let goodMatches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo/" } + ), + ]; + let provider = registerBasicTestProvider(goodMatches); + + let badMatches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/foo/" } + ), + ]; + /** + * A test provider that should not be invoked. + */ + class NoInvokeProvider extends UrlbarProvider { + get name() { + return "BadProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + info("Acceptable sources: " + context.sources); + return context.sources.includes(UrlbarUtils.RESULT_SOURCE.BOOKMARKS); + } + async startQuery(context, add) { + Assert.ok(false, "Provider should no be invoked"); + for (const match of badMatches) { + add(this, match); + } + } + } + let badProvider = new NoInvokeProvider(); + UrlbarProvidersManager.registerProvider(badProvider); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.TABS], + providers: [provider.name, "BadProvider"], + }); + let controller = UrlbarTestUtils.newMockController(); + + info("Only tabs should be returned"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Assert.deepEqual(context.results.length, 1, "Should find only one match"); + Assert.deepEqual( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE.TABS, + "Should find only a tab match" + ); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarProvidersManager.unregisterProvider(badProvider); +}); + +add_task(async function test_filter_queryContext() { + let provider = registerBasicTestProvider(); + + /** + * A test provider that should not be invoked because of queryContext.providers. + */ + class NoInvokeProvider extends UrlbarProvider { + get name() { + return "BadProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + return true; + } + async startQuery(context, add) { + Assert.ok(false, "Provider should no be invoked"); + } + } + let badProvider = new NoInvokeProvider(); + UrlbarProvidersManager.registerProvider(badProvider); + + let context = createContext(undefined, { + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + await controller.startQuery(context, controller); + UrlbarProvidersManager.unregisterProvider(provider); + UrlbarProvidersManager.unregisterProvider(badProvider); +}); + +add_task(async function test_nofilter_heuristic() { + // Checks that even if a provider returns a result that should be filtered out + // it will still be invoked if it's of type heuristic, and only the heuristic + // result is returned. + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo2/" } + ), + ]; + matches[0].heuristic = true; + let provider = registerBasicTestProvider( + matches, + undefined, + UrlbarUtils.PROVIDER_TYPE.HEURISTIC + ); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + providers: [provider.name], + }); + let controller = UrlbarTestUtils.newMockController(); + + // Disable search matches through prefs. + Services.prefs.setBoolPref("browser.urlbar.suggest.openpage", false); + info("Only 1 heuristic tab result should be returned"); + let promise = promiseControllerNotification(controller, "onQueryResults"); + await controller.startQuery(context, controller); + await promise; + Services.prefs.clearUserPref("browser.urlbar.suggest.openpage"); + Assert.deepEqual(context.results.length, 1, "Should find only one match"); + Assert.deepEqual( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE.TABS, + "Should find only a tab match" + ); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_nofilter_restrict() { + // Checks that even if a pref is disabled, we still return results on a + // restriction token. + let matches = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: "http://mozilla.org/foo_tab/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + { url: "http://mozilla.org/foo_bookmark/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/foo_history/" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { engine: "noengine" } + ), + ]; + /** + * A test provider. + */ + class TestProvider extends UrlbarProvider { + get name() { + return "MyProvider"; + } + get type() { + return UrlbarUtils.PROVIDER_TYPE.PROFILE; + } + isActive(context) { + Assert.equal(context.sources.length, 1, "Check acceptable sources"); + return true; + } + async startQuery(context, add) { + Assert.ok(true, "expected provider was invoked"); + for (let match of matches) { + add(this, match); + } + } + } + let provider = new TestProvider(); + UrlbarProvidersManager.registerProvider(provider); + + let typeToPropertiesMap = new Map([ + ["HISTORY", { source: "HISTORY", pref: "history" }], + ["BOOKMARK", { source: "BOOKMARKS", pref: "bookmark" }], + ["OPENPAGE", { source: "TABS", pref: "openpage" }], + ["SEARCH", { source: "SEARCH", pref: "searches" }], + ]); + for (let [type, token] of Object.entries(UrlbarTokenizer.RESTRICT)) { + let properties = typeToPropertiesMap.get(type); + if (!properties) { + continue; + } + info("Restricting on " + type); + let context = createContext(token + " foo", { + providers: ["MyProvider"], + }); + let controller = UrlbarTestUtils.newMockController(); + // Disable the corresponding pref. + const pref = "browser.urlbar.suggest." + properties.pref; + info("Disabling " + pref); + Services.prefs.setBoolPref(pref, false); + await controller.startQuery(context, controller); + Assert.equal(context.results.length, 1, "Should find one result"); + Assert.equal( + context.results[0].source, + UrlbarUtils.RESULT_SOURCE[properties.source], + "Check result source" + ); + Services.prefs.clearUserPref(pref); + } + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function test_filter_priority() { + /** + * A test provider. + */ + class TestProvider extends UrlbarTestUtils.TestProvider { + constructor(priority, shouldBeInvoked, namePart = "") { + super(); + this._priority = priority; + this._name = `${priority}` + namePart; + this._shouldBeInvoked = shouldBeInvoked; + } + async startQuery(context, add) { + Assert.ok(this._shouldBeInvoked, `${this.name} was invoked`); + } + } + + // Test all possible orderings of the providers to make sure the logic that + // finds the highest priority providers is correct. + let providerPerms = permute([ + new TestProvider(0, false), + new TestProvider(1, false), + new TestProvider(2, true, "a"), + new TestProvider(2, true, "b"), + ]); + for (let providers of providerPerms) { + for (let provider of providers) { + UrlbarProvidersManager.registerProvider(provider); + } + let providerNames = providers.map(p => p.name); + let context = createContext(undefined, { providers: providerNames }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context, controller); + for (let name of providerNames) { + UrlbarProvidersManager.unregisterProvider({ name }); + } + } +}); + +function permute(objects) { + if (objects.length <= 1) { + return [objects]; + } + let perms = []; + for (let i = 0; i < objects.length; i++) { + let otherObjects = objects.slice(); + otherObjects.splice(i, 1); + let otherPerms = permute(otherObjects); + for (let perm of otherPerms) { + perm.unshift(objects[i]); + } + perms = perms.concat(otherPerms); + } + return perms; +} diff --git a/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js b/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js new file mode 100644 index 0000000000..b30b9352cd --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager_maxResults.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_maxResults() { + const MATCHES_LENGTH = 20; + let matches = []; + for (let i = 0; i < MATCHES_LENGTH; i++) { + matches.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: `http://mozilla.org/foo/${i}` } + ) + ); + } + let provider = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + async function test_count(count) { + let promise = promiseControllerNotification(controller, "onQueryFinished"); + context.maxResults = count; + await controller.startQuery(context); + await promise; + Assert.equal( + context.results.length, + Math.min(MATCHES_LENGTH, count), + "Check count" + ); + Assert.deepEqual(context.results, matches.slice(0, count), "Check results"); + } + await test_count(10); + await test_count(1); + await test_count(30); +}); diff --git a/browser/components/urlbar/tests/unit/test_queryScorer.js b/browser/components/urlbar/tests/unit/test_queryScorer.js new file mode 100644 index 0000000000..1d6171eac4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_queryScorer.js @@ -0,0 +1,405 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + QueryScorer: "resource:///modules/UrlbarProviderInterventions.sys.mjs", +}); + +const DISTANCE_THRESHOLD = 1; + +const DOCUMENTS = { + clear: [ + "cache firefox", + "clear cache firefox", + "clear cache in firefox", + "clear cookies firefox", + "clear firefox cache", + "clear history firefox", + "cookies firefox", + "delete cookies firefox", + "delete history firefox", + "firefox cache", + "firefox clear cache", + "firefox clear cookies", + "firefox clear history", + "firefox cookie", + "firefox cookies", + "firefox delete cookies", + "firefox delete history", + "firefox history", + "firefox not loading pages", + "history firefox", + "how to clear cache", + "how to clear history", + ], + refresh: [ + "firefox crashing", + "firefox keeps crashing", + "firefox not responding", + "firefox not working", + "firefox refresh", + "firefox slow", + "how to reset firefox", + "refresh firefox", + "reset firefox", + ], + update: [ + "download firefox", + "download mozilla", + "firefox browser", + "firefox download", + "firefox for mac", + "firefox for windows", + "firefox free download", + "firefox install", + "firefox installer", + "firefox latest version", + "firefox mac", + "firefox quantum", + "firefox update", + "firefox version", + "firefox windows", + "get firefox", + "how to update firefox", + "install firefox", + "mozilla download", + "mozilla firefox 2019", + "mozilla firefox 2020", + "mozilla firefox download", + "mozilla firefox for mac", + "mozilla firefox for windows", + "mozilla firefox free download", + "mozilla firefox mac", + "mozilla firefox update", + "mozilla firefox windows", + "mozilla update", + "update firefox", + "update mozilla", + "www.firefox.com", + ], +}; + +const VARIATIONS = new Map([["firefox", ["fire fox", "fox fire", "foxfire"]]]); + +let tests = [ + { + query: "firefox", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "bogus", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "no match", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // clear + { + query: "firefox histo", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox histor", + matches: [ + { id: "clear", score: 1 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox history we'll keep matching once we match", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "firef history", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo history", + matches: [ + { id: "clear", score: 1 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo histor", + matches: [ + { id: "clear", score: 2 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo histor we'll keep matching once we match", + matches: [ + { id: "clear", score: 2 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "fire fox history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "fox fire history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "foxfire history", + matches: [ + { id: "clear", score: 0 }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // refresh + { + query: "firefox sl", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slo", + matches: [ + { id: "refresh", score: 1 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox slow we'll keep matching once we match", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "firef slow", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slow", + matches: [ + { id: "refresh", score: 1 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slo", + matches: [ + { id: "refresh", score: 2 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo slo we'll keep matching once we match", + matches: [ + { id: "refresh", score: 2 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + { + query: "fire fox slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "fox fire slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "foxfire slow", + matches: [ + { id: "refresh", score: 0 }, + { id: "clear", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + + // update + { + query: "firefox upda", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefox updat", + matches: [ + { id: "update", score: 1 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefox update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefox update we'll keep matching once we match", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + + { + query: "firef update", + matches: [ + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + { id: "update", score: Infinity }, + ], + }, + { + query: "firefo update", + matches: [ + { id: "update", score: 1 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefo updat", + matches: [ + { id: "update", score: 2 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "firefo updat we'll keep matching once we match", + matches: [ + { id: "update", score: 2 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + + { + query: "fire fox update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "fox fire update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, + { + query: "foxfire update", + matches: [ + { id: "update", score: 0 }, + { id: "clear", score: Infinity }, + { id: "refresh", score: Infinity }, + ], + }, +]; + +add_task(async function test() { + let qs = new QueryScorer({ + distanceThreshold: DISTANCE_THRESHOLD, + variations: VARIATIONS, + }); + + for (let [id, phrases] of Object.entries(DOCUMENTS)) { + qs.addDocument({ id, phrases }); + } + + for (let { query, matches } of tests) { + let actual = qs + .score(query) + .map(result => ({ id: result.document.id, score: result.score })); + Assert.deepEqual(actual, matches, `Query: "${query}"`); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_query_url.js b/browser/components/urlbar/tests/unit/test_query_url.js new file mode 100644 index 0000000000..e49c966d76 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_query_url.js @@ -0,0 +1,121 @@ +/* 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/. */ + +const PLACES_PROVIDERNAME = "Places"; + +testEngine_setup(); + +add_task(async function test_no_slash() { + info("Searching for host match without slash should match host"); + await PlacesTestUtils.addVisits([ + { uri: "http://file.org/test/" }, + { uri: "file:///c:/test.html" }, + ]); + let context = createContext("file", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/", + completed: "http://file.org/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/", + fallbackTitle: "file.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: PLACES_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_w_slash() { + info("Searching match with slash at the end should match url"); + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("http://file.org/test/"), + }, + { + uri: Services.io.newURI("file:///c:/test.html"), + } + ); + let context = createContext("file.org/", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/", + completed: "http://file.org/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/", + fallbackTitle: "file.org/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_middle() { + info("Searching match with slash in the middle should match url"); + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("http://file.org/test/"), + }, + { + uri: Services.io.newURI("file:///c:/test.html"), + } + ); + let context = createContext("file.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "file.org/test/", + completed: "http://file.org/test/", + matches: [ + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_nonhost() { + info("Searching for non-host match without slash should not match url"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("file:///c:/test.html"), + }); + let context = createContext("file", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: PLACES_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_quickactions.js b/browser/components/urlbar/tests/unit/test_quickactions.js new file mode 100644 index 0000000000..ddeb5a7561 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_quickactions.js @@ -0,0 +1,126 @@ +/* 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/. */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderQuickActions: + "resource:///modules/UrlbarProviderQuickActions.sys.mjs", +}); + +let expectedMatch = (key, inputLength) => ({ + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.ACTIONS, + heuristic: false, + payload: { + results: [{ key }], + dynamicType: "quickactions", + helpUrl: UrlbarProviderQuickActions.helpUrl, + inputLength, + }, +}); + +testEngine_setup(); + +add_task(async function init() { + UrlbarPrefs.set("quickactions.enabled", true); + UrlbarPrefs.set("suggest.quickactions", true); + + UrlbarProviderQuickActions.addAction("newaction", { + commands: ["newaction"], + }); + + registerCleanupFunction(async () => { + UrlbarPrefs.clear("quickactions.enabled"); + UrlbarPrefs.clear("suggest.quickactions"); + UrlbarProviderQuickActions.removeAction("newaction"); + }); +}); + +add_task(async function nomatch() { + let context = createContext("this doesnt match", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); +}); + +add_task(async function quickactions_disabled() { + UrlbarPrefs.set("suggest.quickactions", false); + let context = createContext("new", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); +}); + +add_task(async function quickactions_match() { + UrlbarPrefs.set("suggest.quickactions", true); + let context = createContext("new", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedMatch("newaction", 3)], + }); +}); + +add_task(async function duplicate_matches() { + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction", "test"], + }); + + let context = createContext("testaction", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [expectedMatch("testaction", 10)], + }); + + UrlbarProviderQuickActions.removeAction("testaction"); +}); + +add_task(async function remove_action() { + UrlbarProviderQuickActions.addAction("testaction", { + commands: ["testaction"], + }); + UrlbarProviderQuickActions.removeAction("testaction"); + + let context = createContext("test", { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [], + }); +}); + +add_task(async function minimum_search_string() { + let searchString = "newa"; + for (let minimumSearchString of [0, 3]) { + UrlbarPrefs.set("quickactions.minimumSearchString", minimumSearchString); + for (let i = 1; i < 4; i++) { + let context = createContext(searchString.substring(0, i), { + providers: [UrlbarProviderQuickActions.name], + isPrivate: false, + }); + let matches = + i >= minimumSearchString ? [expectedMatch("newaction", i)] : []; + await check_results({ context, matches }); + } + } + UrlbarPrefs.clear("quickactions.minimumSearchString"); +}); diff --git a/browser/components/urlbar/tests/unit/test_remote_tabs.js b/browser/components/urlbar/tests/unit/test_remote_tabs.js new file mode 100644 index 0000000000..5b834c876e --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_remote_tabs.js @@ -0,0 +1,695 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + */ +"use strict"; + +const { Weave } = ChromeUtils.importESModule( + "resource://services-sync/main.sys.mjs" +); + +// A mock "Tabs" engine which autocomplete will use instead of the real +// engine. We pass a constructor that Sync creates. +function MockTabsEngine() { + this.clients = null; // We'll set this dynamically +} + +MockTabsEngine.prototype = { + name: "tabs", + + startTracking() {}, + getAllClients() { + return this.clients; + }, +}; + +// A clients engine that doesn't need to be a constructor. +let MockClientsEngine = { + getClientType(guid) { + Assert.ok(guid.endsWith("desktop") || guid.endsWith("mobile")); + return guid.endsWith("mobile") ? "phone" : "desktop"; + }, + remoteClientExists(id) { + return true; + }, + getClientName(id) { + return id.endsWith("mobile") ? "My Phone" : "My Desktop"; + }, +}; + +// Configure the singleton engine for a test. +function configureEngine(clients) { + // Configure the instance Sync created. + let engine = Weave.Service.engineManager.get("tabs"); + engine.clients = clients; + Weave.Service.clientsEngine = MockClientsEngine; + // Send an observer that pretends the engine just finished a sync. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); +} + +testEngine_setup(); + +add_task(async function setup() { + // Tell Sync about the mocks. + Weave.Service.engineManager.register(MockTabsEngine); + + // Tell the Sync XPCOM service it is initialized. + let weaveXPCService = Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; + weaveXPCService.ready = true; + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("services.sync.username"); + Services.prefs.clearUserPref("services.sync.registerEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + await cleanupPlaces(); + }); + + Services.prefs.setCharPref("services.sync.username", "someone@somewhere.com"); + Services.prefs.setCharPref("services.sync.registerEngines", ""); + // Avoid hitting the network. + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); +}); + +add_task(async function test_minimal() { + // The minimal client and tabs info we can get away with. + configureEngine([ + { + id: "desktop", + tabs: [ + { + urlHistory: ["http://example.com/"], + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Desktop", + }), + ], + }); +}); + +add_task(async function test_maximal() { + // Every field that could possibly exist on a remote record. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Phone", + title: "An Example", + iconUri: "moz-anno:favicon:http://favicon/", + }), + ], + }); +}); + +add_task(async function test_noShowIcons() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteIcons", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://example.com/", + device: "My Phone", + title: "An Example", + // expecting the default favicon due to that pref. + iconUri: "", + }), + ], + }); + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteIcons"); +}); + +add_task(async function test_dontMatchSyncedTabs() { + Services.prefs.setBoolPref("services.sync.syncedTabs.showRemoteTabs", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("services.sync.syncedTabs.showRemoteTabs"); +}); + +add_task(async function test_tabsDisabledInUrlbar() { + Services.prefs.setBoolPref("browser.urlbar.suggest.remotetab", false); + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://example.com/"], + title: "An Example", + icon: "http://favicon", + }, + ], + }, + ]); + + let context = createContext("ex", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.suggest.remotetab"); +}); + +add_task(async function test_matches_title() { + // URL doesn't match search expression, should still match the title. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "An Example", + }, + ], + }, + ]); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.com/", + device: "My Phone", + title: "An Example", + }), + ], + }); +}); + +add_task(async function test_localtab_matches_override() { + // We have an open tab to the same page on a remote device, only "switch to + // tab" should appear as duplicate detection removed the remote one. + + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: ["http://foo.com/"], + title: "An Example", + }, + ], + }, + ]); + + // Set up Places to think the tab is open locally. + let uri = Services.io.newURI("http://foo.com/"); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://foo.com/", + title: "An Example", + }), + ], + }); + + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_remotetab_matches_override() { + // If we have an history result to the same page, we should only get the + // remote tab match. + let url = "http://foo.remote.com/"; + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs: [ + { + urlHistory: [url], + title: "An Example", + }, + ], + }, + ]); + + // Set up Places to think the tab is in history. + await PlacesTestUtils.addVisits(url); + + let query = "ex"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/", + device: "My Phone", + title: "An Example", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_mixed_result_types() { + // In case we have many results, non-remote results should flex to the bottom. + let url = "http://foo.remote.com/"; + let tabs = Array(6) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i * 86400, // i days ago. + })); + // First set up Sync to have the page as a remote tab. + configureEngine([{ id: "mobile", tabs }]); + + // Register the page as an open tab. + let openTabUrl = url + "openpage/"; + let uri = Services.io.newURI(openTabUrl); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + // Also add a local history result. + let historyUrl = url + "history/"; + await PlacesTestUtils.addVisits(historyUrl); + + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/5", + device: "My Phone", + title: "A title", + lastUsed: tabs[5].lastUsed, + }), + makeVisitResult(context, { + uri: historyUrl, + title: "test visit for " + historyUrl, + }), + makeTabSwitchResult(context, { + uri: openTabUrl, + title: "An Example", + }), + ], + }); + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_many_remotetab_results() { + let url = "http://foo.remote.com/"; + let tabs = Array(8) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i * 86400, // i days old. + })); + + // First set up Sync to have the page as a remote tab. + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/5", + device: "My Phone", + title: "A title", + lastUsed: tabs[5].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/6", + device: "My Phone", + title: "A title", + lastUsed: tabs[6].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/7", + device: "My Phone", + title: "A title", + lastUsed: tabs[7].lastUsed, + }), + ], + }); +}); + +add_task(async function multiple_clients() { + let url = "http://foo.remote.com/"; + let mobileTabs = Array(2) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}mobile/${i}`], + lastUsed: Date.now() / 1000 - 4 * 86400, // 4 days old (past threshold) + })); + + let desktopTabs = Array(3) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}desktop/${i}`], + lastUsed: Date.now() / 1000 - 1, // Fresh tabs + })); + + // mobileTabs has the most recent tab, making it the most recent client. The + // rest of its tabs are stale. The tabs in desktopTabs are fresh, but not + // as fresh as the most recent tab in mobileTab. + mobileTabs.push({ + urlHistory: [`${url}mobile/fresh`], + lastUsed: Date.now() / 1000, + }); + + configureEngine([ + { + id: "mobile", + tabs: mobileTabs, + }, + { + id: "desktop", + tabs: desktopTabs, + }, + ]); + + // We expect that we will show the recent tab from mobileTabs, then all the + // tabs from desktopTabs, then the remaining tabs from mobileTabs. + let query = "rem"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/fresh", + device: "My Phone", + lastUsed: mobileTabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/0", + device: "My Desktop", + lastUsed: desktopTabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/1", + device: "My Desktop", + lastUsed: desktopTabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/desktop/2", + device: "My Desktop", + lastUsed: desktopTabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/0", + device: "My Phone", + lastUsed: mobileTabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/mobile/1", + device: "My Phone", + lastUsed: mobileTabs[1].lastUsed, + }), + ], + }); +}); + +add_task(async function test_restrictionCharacter() { + let url = "http://foo.remote.com/"; + let tabs = Array(5) + .fill(0) + .map((e, i) => ({ + urlHistory: [`${url}${i}`], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000) - i, + })); + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + // Also add an open page. + let openTabUrl = url + "openpage/"; + let uri = Services.io.newURI(openTabUrl); + await PlacesTestUtils.addVisits([{ uri, title: "An Example" }]); + await addOpenPages(uri, 1); + + // We expect the open tab to flex to the bottom. + let query = UrlbarTokenizer.RESTRICT.OPENPAGE; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/0", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/1", + device: "My Phone", + title: "A title", + lastUsed: tabs[1].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/2", + device: "My Phone", + title: "A title", + lastUsed: tabs[2].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/3", + device: "My Phone", + title: "A title", + lastUsed: tabs[3].lastUsed, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/4", + device: "My Phone", + title: "A title", + lastUsed: tabs[4].lastUsed, + }), + makeTabSwitchResult(context, { + uri: openTabUrl, + title: "An Example", + }), + ], + }); + await removeOpenPages(uri, 1); + await cleanupPlaces(); +}); + +add_task(async function test_duplicate_remote_tabs() { + let url = "http://foo.remote.com/"; + let tabs = Array(3) + .fill(0) + .map((e, i) => ({ + urlHistory: [url], + title: "A title", + lastUsed: Math.floor(Date.now() / 1000), + })); + configureEngine([ + { + id: "mobile", + tabs, + }, + ]); + + // We expect the duplicate tabs to be deduped. + let query = UrlbarTokenizer.RESTRICT.OPENPAGE; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeRemoteTabResult(context, { + uri: "http://foo.remote.com/", + device: "My Phone", + title: "A title", + lastUsed: tabs[0].lastUsed, + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_resultGroups.js b/browser/components/urlbar/tests/unit/test_resultGroups.js new file mode 100644 index 0000000000..e385f7d2fb --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_resultGroups.js @@ -0,0 +1,1576 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the muxer's result groups composition logic: child groups, +// `availableSpan`, `maxResultCount`, flex, etc. The purpose of this test is to +// check the composition logic, not every possible result type or group. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// The possible limit-related properties in result groups. +const LIMIT_KEYS = ["availableSpan", "maxResultCount"]; + +// Most of this test adds tasks using `add_resultGroupsLimit_tasks`. It works +// like this. Instead of defining `maxResultCount` or `availableSpan` in their +// result groups, tasks define a `limit` property. The value of this property is +// a number just like any of the values for the limit-related properties. At +// runtime, `add_resultGroupsLimit_tasks` adds multiple tasks, one for each key +// in `LIMIT_KEYS`. In each of these tasks, the `limit` property is replaced +// with the actual limit key. This allows us to run checks against each of the +// limit keys using essentially the same task. + +const MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; + +// For simplicity, most of the flex tests below assume that this is 10, so +// you'll need to update them if you change this. +const MAX_RESULTS = 10; + +let sandbox; + +add_task(async function setup() { + // Set a specific maxRichResults for sanity's sake. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, MAX_RESULTS); + + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_resultGroupsLimit_tasks({ + testName: "empty root", + resultGroups: {}, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root with empty children", + resultGroups: { + children: [], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root no match", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "children no match", + resultGroups: { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + // The actual max result count on the root is always context.maxResults and + // limit is ignored, so we expect the result in this case. + testName: "root limit: 0", + resultGroups: { + limit: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + // The actual max result count on the root is always context.maxResults and + // limit is ignored, so we expect the result in this case. + testName: "root limit: 0 with children", + resultGroups: { + limit: 0, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "child limit: 0", + resultGroups: { + children: [ + { + limit: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [], +}); + +add_resultGroupsLimit_tasks({ + testName: "root group", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(1)], + expectedResultIndexes: [...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "root group multiple", + resultGroups: { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "child group multiple", + resultGroups: { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "simple limit", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit siblings", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested", + resultGroups: { + children: [ + { + limit: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested siblings", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested uncle", + resultGroups: { + children: [ + { + limit: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0, 1], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested override bad", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { + limit: 99, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit nested override good", + resultGroups: { + children: [ + { + limit: 99, + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeHistoryResults(2)], + expectedResultIndexes: [0], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 1", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 2", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 3", + resultGroups: { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups limit 4", + resultGroups: { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested 1", + resultGroups: { + children: [ + { + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested 2", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 1", + resultGroups: { + children: [ + { + limit: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 2", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 1), ...makeIndexRange(0, 2)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 3", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 4", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 5", + resultGroups: { + children: [ + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [...makeIndexRange(2, 2), ...makeIndexRange(0, 1)], +}); + +add_resultGroupsLimit_tasks({ + testName: "multiple groups nested limit 6", + resultGroups: { + children: [ + { + children: [ + { + limit: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(2), + ...makeHistoryResults(2), + ], + expectedResultIndexes: [ + ...makeIndexRange(2, 1), + ...makeIndexRange(0, 2), + ...makeIndexRange(3, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / (1 + 1))) = 5 + ...makeIndexRange(MAX_RESULTS, 5), + // remote suggestions: round(10 * (1 / (1 + 1))) = 5 + ...makeIndexRange(0, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / 3)) = 7 + ...makeIndexRange(MAX_RESULTS, 7), + // remote suggestions: round(10 * (1 / 3)) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 3", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 3)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // remote suggestions: round(10 * (2 / 3)) = 7 + ...makeIndexRange(0, 7), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 4", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 3)) = 3, and then incremented to 4 so + // that the total result span is 10 instead of 9. This group is incremented + // because the fractional part of its unrounded ideal max result count is + // 0.33 (since 10 * (1 / 3) = 3.33), the same as the other two groups, and + // this group is first. + ...makeIndexRange(2 * MAX_RESULTS, 4), + // remote suggestions: round(10 * (1 / 3)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // form history: round(10 * (1 / 3)) = 3 + // The first three form history results dupe the three remote suggestions, + // so they should not be included. + ...makeIndexRange(3, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 5", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / 4)) = 5 + ...makeIndexRange(2 * MAX_RESULTS, 5), + // remote suggestions: round(10 * (1 / 4)) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // form history: round(10 * (1 / 4)) = 3, but context.maxResults is 10, so 2 + // The first three form history results dupe the three remote suggestions, + // so they should not be included. + ...makeIndexRange(3, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 6", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 4)) = 3 + ...makeIndexRange(2 * MAX_RESULTS, 3), + // remote suggestions: round(10 * (2 / 4)) = 5 + ...makeIndexRange(MAX_RESULTS, 5), + // form history: round(10 * (1 / 4)) = 3, but context.maxResults is 10, so 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(5, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex 7", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (1 / 4)) = 3 + ...makeIndexRange(2 * MAX_RESULTS, 3), + // remote suggestions: round(10 * (1 / 4)) = 3, and then decremented to 2 so + // that the total result span is 10 instead of 11. This group is decremented + // because the fractional part of its unrounded ideal max result count is + // 0.5 (since 10 * (1 / 4) = 2.5), the same as the previous group, and the + // next group's fractional part is zero. + ...makeIndexRange(MAX_RESULTS, 2), + // form history: round(10 * (2 / 4)) = 5 + // The first 2 form history results dupe the three remote suggestions, so + // they should not be included. + ...makeIndexRange(2, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex overfill 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / (2 + 0 + 1))) = 7 + ...makeIndexRange(MAX_RESULTS, 7), + // form history: round(10 * (1 / (2 + 0 + 1))) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex overfill 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeFormHistoryResults(MAX_RESULTS), + ...makeRemoteSuggestionResults(1), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(9 * (2 / (2 + 0 + 1))) = 6 + ...makeIndexRange(MAX_RESULTS + 1, 6), + // remote suggestions + ...makeIndexRange(MAX_RESULTS, 1), + // form history: round(9 * (1 / (2 + 0 + 1))) = 3 + // The first form history result dupes the remote suggestion, so it should + // not be included. + ...makeIndexRange(1, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 1", + resultGroups: { + children: [ + { + limit: 5, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(5 * (2 / (2 + 1))) = 3 + ...makeIndexRange(MAX_RESULTS, 3), + // remote suggestions: round(5 * (1 / (2 + 1))) = 2 + ...makeIndexRange(0, 2), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 2", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 3", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 2), + // general: round(3 * (1 / (2 + 1))) = 1 + ...makeIndexRange(2 * MAX_RESULTS + 2, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 4", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested limit 5", + resultGroups: { + children: [ + { + limit: 7, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + limit: 3, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + // form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: round(7 * (1 / (2 + 1))) = 2 + ...makeIndexRange(2 * MAX_RESULTS, 2), + // inner 2: remote suggestions: round(7 * (2 / (2 + 1))) = 5 + ...makeIndexRange(0, 5), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: round(3 * (2 / (2 + 1))) = 2 + // The first five form history results dupe the five remote suggestions, so + // they should not be included. + ...makeIndexRange(MAX_RESULTS + 5, 2), + // inner 2: general: round(3 * (1 / (2 + 1))) = 1 + ...makeIndexRange(2 * MAX_RESULTS + 2, 1), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 1", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeFormHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: no results + // inner 2: remote suggestions: round(7 * (2 / (2 + 0))) = 7 + ...makeIndexRange(0, 7), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: round(3 * (2 / (2 + 0))) = 3 + // The first seven form history results dupe the seven remote suggestions, + // so they should not be included. + ...makeIndexRange(MAX_RESULTS + 7, 3), + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 2", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeFormHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 1))) = 7 + // inner 1: general: no results + // inner 2: remote suggestions: no results + + // outer 2: form history & general: round(10 * (1 / (0 + 1))) = 10 + // inner 1: form history: round(10 * (2 / (2 + 0))) = 10 + ...makeIndexRange(0, MAX_RESULTS), + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "flex nested overfill 3", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + { + flex: 1, + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + ], + }, + providerResults: [...makeRemoteSuggestionResults(MAX_RESULTS)], + expectedResultIndexes: [ + // outer 1: general & remote suggestions: round(10 * (2 / (2 + 0))) = 10 + // inner 1: general: no results + // inner 2: remote suggestions: round(10 * (2 / (2 + 0))) = 10 + ...makeIndexRange(0, MAX_RESULTS), + + // outer 2: form history & general: round(10 * (1 / (2 + 1))) = 3 + // inner 1: form history: no results + // inner 2: general: no results + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "limit ignored with flex", + resultGroups: { + flexChildren: true, + children: [ + { + limit: 1, + flex: 2, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + ...makeRemoteSuggestionResults(MAX_RESULTS), + ...makeHistoryResults(MAX_RESULTS), + ], + expectedResultIndexes: [ + // general/history: round(10 * (2 / (2 + 1))) = 7 -- limit ignored + ...makeIndexRange(MAX_RESULTS, 7), + // remote suggestions: round(10 * (1 / (2 + 1))) = 3 + ...makeIndexRange(0, 3), + ], +}); + +add_resultGroupsLimit_tasks({ + testName: "resultSpan = 3 followed by others", + resultGroups: { + children: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + providerResults: [ + // max results remote suggestions + ...makeRemoteSuggestionResults(MAX_RESULTS), + // 1 history with resultSpan = 3 + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + ], + expectedResultIndexes: [ + // general/history: 1 + ...makeIndexRange(MAX_RESULTS, 1), + // remote suggestions: maxResults - resultSpan of 3 = 10 - 3 = 7 + ...makeIndexRange(0, 7), + ], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 1, availableSpan: 3", + resultGroups: { + children: [ + { + maxResultCount: 1, + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 1, availableSpan: 3, resultSpan = 3", + resultGroups: { + children: [ + { + maxResultCount: 1, + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 }), + ], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 3, availableSpan: 1", + resultGroups: { + children: [ + { + maxResultCount: 3, + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "maxResultCount: 3, availableSpan: 1, resultSpan = 3", + resultGroups: { + children: [ + { + maxResultCount: 3, + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 })], + expectedResultIndexes: [], +}); + +add_resultGroups_task({ + testName: "availableSpan: 1", + resultGroups: { + children: [ + { + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [...makeHistoryResults(MAX_RESULTS)], + expectedResultIndexes: [0], +}); + +add_resultGroups_task({ + testName: "availableSpan: 1, resultSpan = 3", + resultGroups: { + children: [ + { + availableSpan: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [Object.assign(makeHistoryResults(1)[0], { resultSpan: 3 })], + expectedResultIndexes: [], +}); + +add_resultGroups_task({ + testName: "availableSpan: 3, resultSpan = 2 and resultSpan = 1", + resultGroups: { + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + makeHistoryResults(1)[0], + Object.assign(makeHistoryResults(1)[0], { resultSpan: 2 }), + makeHistoryResults(1)[0], + ], + expectedResultIndexes: [0, 1], +}); + +add_resultGroups_task({ + testName: "availableSpan: 3, resultSpan = 1 and resultSpan = 2", + resultGroups: { + children: [ + { + availableSpan: 3, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + }, + providerResults: [ + Object.assign(makeHistoryResults(1)[0], { resultSpan: 2 }), + makeHistoryResults(1)[0], + makeHistoryResults(1)[0], + ], + expectedResultIndexes: [0, 1], +}); + +/** + * Adds a single test task. + * + * @param {object} options + * The options for the test + * @param {string} options.testName + * This name is logged with `info` as the task starts. + * @param {object} options.resultGroups + * browser.urlbar.resultGroups is set to this value as the task starts. + * @param {Array} options.providerResults + * Array of result objects that the test provider will add. + * @param {Array} options.expectedResultIndexes + * Array of indexes in `providerResults` of the expected final results. + */ +function add_resultGroups_task({ + testName, + resultGroups, + providerResults, + expectedResultIndexes, +}) { + let func = async () => { + info(`Running resultGroups test: ${testName}`); + info(`Setting result groups: ` + JSON.stringify(resultGroups)); + setResultGroups(resultGroups); + let provider = registerBasicTestProvider(providerResults); + let context = createContext("foo", { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + await UrlbarProvidersManager.startQuery(context, controller); + UrlbarProvidersManager.unregisterProvider(provider); + let expectedResults = expectedResultIndexes.map(i => providerResults[i]); + Assert.deepEqual(context.results, expectedResults); + setResultGroups(null); + }; + Object.defineProperty(func, "name", { value: testName }); + add_task(func); +} + +/** + * Adds test tasks for each of the keys in `LIMIT_KEYS`. + * + * @param {object} options + * The options for the test + * @param {string} options.testName + * The name of the test. + * @param {object} options.resultGroups + * The resultGroups object to set. + * @param {Array} options.providerResults + * The results to return from the test + * @param {Array} options.expectedResultIndexes + * Indexes of the expected results within {@link providerResults} + */ +function add_resultGroupsLimit_tasks({ + testName, + resultGroups, + providerResults, + expectedResultIndexes, +}) { + for (let key of LIMIT_KEYS) { + add_resultGroups_task({ + testName: `${testName} (limit: ${key})`, + resultGroups: replaceLimitWithKey(resultGroups, key), + providerResults, + expectedResultIndexes, + }); + } +} + +function replaceLimitWithKey(group, key) { + group = { ...group }; + if ("limit" in group) { + group[key] = group.limit; + delete group.limit; + } + for (let i = 0; i < group.children?.length; i++) { + group.children[i] = replaceLimitWithKey(group.children[i], key); + } + return group; +} + +function makeHistoryResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/" + i } + ) + ); + } + return results; +} + +function makeRemoteSuggestionResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + query: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeFormHistoryResults(count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeIndexRange(startIndex, count) { + let indexes = []; + for (let i = startIndex; i < startIndex + count; i++) { + indexes.push(i); + } + return indexes; +} + +function setResultGroups(resultGroups) { + sandbox.restore(); + if (resultGroups) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => resultGroups); + } +} diff --git a/browser/components/urlbar/tests/unit/test_search_engine_host.js b/browser/components/urlbar/tests/unit/test_search_engine_host.js new file mode 100644 index 0000000000..8135566547 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_engine_host.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +let engine; + +add_task(async function test_searchEngine_autoFill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + await SearchTestUtils.installSearchExtension({ + name: "MySearchEngine", + search_url: "https://my.search.com/", + }); + engine = Services.search.getEngineByName("MySearchEngine"); + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + // Add an uri that matches the search string with high frecency. + let uri = Services.io.newURI("http://www.example.com/my/"); + let visits = []; + for (let i = 0; i < 100; ++i) { + visits.push({ uri, title: "Terms - SearchEngine Search" }); + } + await PlacesTestUtils.addVisits(visits); + await PlacesTestUtils.addBookmarkWithDetails({ + uri, + title: "Example bookmark", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + ok( + (await PlacesTestUtils.getDatabaseValue("moz_places", "frecency", { + url: uri, + })) > 10000, + "Added URI should have expected high frecency" + ); + + info( + "Check search domain is autoFilled even if there's an higher frecency match" + ); + let context = createContext("my", { isPrivate: false }); + await check_results({ + search: "my", + autofilled: "my.search.com/", + matches: [ + makePrioritySearchResult(context, { + engineName: "MySearchEngine", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_noautoFill() { + Services.prefs.setIntPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft", + 0 + ); + await PlacesTestUtils.addVisits( + Services.io.newURI("http://my.search.com/samplepage/") + ); + + info("Check search domain is not autoFilled if it matches a visited domain"); + let context = createContext("my", { isPrivate: false }); + await check_results({ + context, + autofilled: "my.search.com/", + completed: "http://my.search.com/", + matches: [ + // Note this result is a normal Autofill result and not a priority engine. + makeVisitResult(context, { + uri: "http://my.search.com/", + fallbackTitle: "my.search.com", + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + makeVisitResult(context, { + uri: "http://my.search.com/samplepage/", + title: "test visit for http://my.search.com/samplepage/", + providerName: "Places", + }), + ], + }); + + await cleanupPlaces(); + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_engine_restyle.js b/browser/components/urlbar/tests/unit/test_search_engine_restyle.js new file mode 100644 index 0000000000..5bfdd66416 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_engine_restyle.js @@ -0,0 +1,124 @@ +/* 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/. */ + +testEngine_setup(); + +const engineDomain = "s.example.com"; +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", true); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch", + search_url: `https://${engineDomain}/search`, + }); + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.restyleSearches"); + }); +}); + +add_task(async function test_searchEngine() { + let uri = Services.io.newURI(`https://${engineDomain}/search?q=Terms`); + await PlacesTestUtils.addVisits({ + uri, + title: "Terms - SearchEngine Search", + }); + + info("Past search terms should be styled."); + let context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: "MozSearch", + suggestion: "Terms", + }), + ], + }); + + info( + "Searching for a superset of the search string in history should not restyle." + ); + context = createContext("Terms Foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("Bookmarked past searches should not be restyled"); + await PlacesTestUtils.addBookmarkWithDetails({ + uri, + title: "Terms - SearchEngine Search", + }); + + context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); + + await PlacesUtils.bookmarks.eraseEverything(); + + info("Past search terms should not be styled if restyling is disabled"); + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", false); + context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); + Services.prefs.setBoolPref("browser.urlbar.restyleSearches", true); + + await cleanupPlaces(); +}); + +add_task(async function test_extraneousParameters() { + info("SERPs in history with extraneous parameters should not be restyled."); + let uri = Services.io.newURI( + `https://${engineDomain}/search?q=Terms&p=2&type=img` + ); + await PlacesTestUtils.addVisits({ + uri, + title: "Terms - SearchEngine Search", + }); + + let context = createContext("term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri.spec, + title: "Terms - SearchEngine Search", + }), + ], + }); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions.js b/browser/components/urlbar/tests/unit/test_search_suggestions.js new file mode 100644 index 0000000000..1a49ff2e7d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions.js @@ -0,0 +1,2078 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that search engine suggestions are returned by + * UrlbarProviderSearchSuggestions. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_ENABLED_PREF = "browser.search.suggest.enabled.private"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; +const TAB_TO_SEARCH_PREF = "browser.urlbar.suggest.engines"; +const TRENDING_PREF = "browser.urlbar.trending.featureGate"; +const QUICKACTIONS_PREF = "browser.urlbar.suggest.quickactions"; +const MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; +const MAX_FORM_HISTORY_PREF = "browser.urlbar.maxHistoricalSearchSuggestions"; +const SHOW_SEARCH_SUGGESTIONS_FIRST_PREF = + "browser.urlbar.showSearchSuggestionsFirst"; +const SEARCH_STRING = "hello"; + +const MAX_RESULTS = Services.prefs.getIntPref(MAX_RICH_RESULTS_PREF, 10); + +var suggestionsFn; +var previousSuggestionsFn; +let port; +let sandbox; + +/** + * Set the current suggestion funciton. + * + * @param {Function} fn + * A function that that a search string and returns an array of strings that + * will be used as search suggestions. + * Note: `fn` should return > 0 suggestions in most cases. Otherwise, you may + * encounter unexpected behaviour with UrlbarProviderSuggestion's + * _lastLowResultsSearchSuggestion safeguard. + */ +function setSuggestionsFn(fn) { + previousSuggestionsFn = suggestionsFn; + suggestionsFn = fn; +} + +async function cleanup() { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); + sandbox.restore(); +} + +async function cleanUpSuggestions() { + await cleanup(); + if (previousSuggestionsFn) { + suggestionsFn = previousSuggestionsFn; + previousSuggestionsFn = null; + } +} + +function makeFormHistoryResults(context, count) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + makeFormHistoryResult(context, { + suggestion: `${SEARCH_STRING} world Form History ${i}`, + engineName: SUGGESTIONS_ENGINE_NAME, + }) + ); + } + return results; +} + +function makeRemoteSuggestionResults( + context, + { suggestionPrefix = SEARCH_STRING, query = undefined } = {} +) { + // The suggestions function in `setup` returns: + // [searchString, searchString + "foo", searchString + "bar"] + // But when the heuristic is a search result, the muxer discards suggestion + // results that match the search string, and therefore we expect only two + // remote suggestion results, the "foo" and "bar" ones. + return [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: suggestionPrefix + " foo", + }), + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: suggestionPrefix + " bar", + }), + ]; +} + +function setResultGroups(groups) { + sandbox.restore(); + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => { + return { + children: [ + // heuristic + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_EXTENSION }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_SEARCH_TIP }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_OMNIBOX }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_AUTOFILL }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TOKEN_ALIAS_ENGINE }, + { group: UrlbarUtils.RESULT_GROUP.HEURISTIC_FALLBACK }, + ], + }, + // extensions using the omnibox API + { + group: UrlbarUtils.RESULT_GROUP.OMNIBOX, + }, + ...groups, + ], + }; + }); +} + +add_task(async function setup() { + sandbox = lazy.sinon.createSandbox(); + + let engine = await addTestSuggestionsEngine(searchStr => { + return suggestionsFn(searchStr); + }); + port = engine.getSubmission("").uri.port; + + setSuggestionsFn(searchStr => { + let suffixes = ["foo", "bar"]; + return [searchStr].concat(suffixes.map(s => searchStr + " " + s)); + }); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref(TRENDING_PREF); + Services.prefs.clearUserPref(QUICKACTIONS_PREF); + Services.prefs.clearUserPref(TAB_TO_SEARCH_PREF); + sandbox.restore(); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); + Services.prefs.setBoolPref(TRENDING_PREF, false); + Services.prefs.setBoolPref(QUICKACTIONS_PREF, false); + // Tab-to-search engines can introduce unexpected results, espescially because + // they depend on real en-US engines. + Services.prefs.setBoolPref(TAB_TO_SEARCH_PREF, false); + + // Add MAX_RESULTS form history. + let context = createContext(SEARCH_STRING, { isPrivate: false }); + let entries = makeFormHistoryResults(context, MAX_RESULTS).map(r => ({ + value: r.payload.suggestion, + source: SUGGESTIONS_ENGINE_NAME, + })); + await UrlbarTestUtils.formHistory.add(entries); +}); + +add_task(async function disabled_urlbarSuggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_allSuggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_privateWindow() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, false); + let context = createContext(SEARCH_STRING, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task(async function disabled_urlbarSuggestions_withRestrictionToken() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + query: SEARCH_STRING, + }), + ], + }); + await cleanUpSuggestions(); +}); + +add_task( + async function disabled_urlbarSuggestions_withRestrictionToken_private() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, false); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: true } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + await cleanUpSuggestions(); + } +); + +add_task( + async function disabled_urlbarSuggestions_withRestrictionToken_private_enabled() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, true); + let context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { isPrivate: true } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + query: SEARCH_STRING, + }), + ], + }); + await cleanUpSuggestions(); + } +); + +add_task(async function enabled_by_pref_privateWindow() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + Services.prefs.setBoolPref(PRIVATE_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + await cleanUpSuggestions(); + + Services.prefs.clearUserPref(PRIVATE_ENABLED_PREF); +}); + +add_task(async function singleWordQuery() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function multiWordQuery() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + const query = `${SEARCH_STRING} world`; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: query, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function suffixMatch() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + setSuggestionsFn(searchStr => { + let prefixes = ["baz", "quux"]; + return prefixes.map(p => p + " " + searchStr); + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "baz " + SEARCH_STRING, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "quux " + SEARCH_STRING, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function remoteSuggestionsDupeSearchString() { + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 0); + + // Return remote suggestions with the trimmed search string, the uppercased + // search string, and the search string with a trailing space, plus the usual + // "foo" and "bar" suggestions. + setSuggestionsFn(searchStr => { + let suffixes = ["foo", "bar"]; + return [searchStr.trim(), searchStr.toUpperCase(), searchStr + " "].concat( + suffixes.map(s => searchStr + " " + s) + ); + }); + + // Do a search with a trailing space. All the variations of the search string + // with regard to spaces and case should be discarded from the remote + // suggestions, leaving only the usual "foo" and "bar" suggestions. + let query = SEARCH_STRING + " "; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + + await cleanUpSuggestions(); + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); +}); + +add_task(async function queryIsNotASubstring() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + + setSuggestionsFn(searchStr => { + return ["aaa", "bbb"]; + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "aaa", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "bbb", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function restrictToken() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // Add a visit and a bookmark. Actually, make the bookmark visited too so + // that it's guaranteed, with its higher frecency, to appear above the search + // suggestions. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-visit`), + title: `${SEARCH_STRING} visit`, + }, + { + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-bookmark`), + title: `${SEARCH_STRING} bookmark`, + }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: Services.io.newURI(`http://example.com/${SEARCH_STRING}-bookmark`), + title: `${SEARCH_STRING} bookmark`, + }); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + + // Do an unrestricted search to make sure everything appears in it, including + // the visit and bookmark. + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 5), + ...makeRemoteSuggestionResults(context), + makeBookmarkResult(context, { + uri: `http://example.com/${SEARCH_STRING}-bookmark`, + title: `${SEARCH_STRING} bookmark`, + }), + makeVisitResult(context, { + uri: `http://example.com/${SEARCH_STRING}-visit`, + title: `${SEARCH_STRING} visit`, + }), + ], + }); + + // Now do a restricted search to make sure only suggestions appear. + context = createContext( + `${UrlbarTokenizer.RESTRICT.SEARCH} ${SEARCH_STRING}`, + { + isPrivate: false, + } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: SEARCH_STRING, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: SEARCH_STRING, + query: SEARCH_STRING, + }), + ], + }); + + // Typing the search restriction char shows the Search Engine entry and local + // results. + context = createContext(UrlbarTokenizer.RESTRICT.SEARCH, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + // Also if followed by multiple spaces. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH} `, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + // If followed by any char we should fetch suggestions. + // Note this uses "h" to match form history. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH}h`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + query: "h", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "h", + query: "h", + }), + ], + }); + + // Also if followed by a space and single char. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH} h`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "h", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "h", + query: "h", + }), + ], + }); + + // Leading search-mode restriction tokens are removed. + context = createContext( + `${UrlbarTokenizer.RESTRICT.BOOKMARK} ${SEARCH_STRING}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + query: SEARCH_STRING, + alias: UrlbarTokenizer.RESTRICT.BOOKMARK, + }), + makeBookmarkResult(context, { + uri: `http://example.com/${SEARCH_STRING}-bookmark`, + title: `${SEARCH_STRING} bookmark`, + }), + ], + }); + + // Non-search-mode restriction tokens remain in the query and heuristic search + // result. + let token; + for (let t of Object.values(UrlbarTokenizer.RESTRICT)) { + if (!UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(t)) { + token = t; + break; + } + } + Assert.ok( + token, + "Non-search-mode restrict token exists -- if not, you can probably remove me!" + ); + context = createContext(token, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function mixup_frecency() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + + // At most, we should have 22 results in this subtest. We set this to 30 to + // make we're not cutting off any results and we are actually getting 22. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, 30); + + // Add a visit and a bookmark. Actually, make the bookmark visited too so + // that it's guaranteed, with its higher frecency, to appear above the search + // suggestions. + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/lo0"), + title: `${SEARCH_STRING} low frecency 0`, + }, + { + uri: Services.io.newURI("http://example.com/lo1"), + title: `${SEARCH_STRING} low frecency 1`, + }, + { + uri: Services.io.newURI("http://example.com/lo2"), + title: `${SEARCH_STRING} low frecency 2`, + }, + { + uri: Services.io.newURI("http://example.com/lo3"), + title: `${SEARCH_STRING} low frecency 3`, + }, + { + uri: Services.io.newURI("http://example.com/lo4"), + title: `${SEARCH_STRING} low frecency 4`, + }, + ]); + + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/hi0"), + title: `${SEARCH_STRING} high frecency 0`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi1"), + title: `${SEARCH_STRING} high frecency 1`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi2"), + title: `${SEARCH_STRING} high frecency 2`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + { + uri: Services.io.newURI("http://example.com/hi3"), + title: `${SEARCH_STRING} high frecency 3`, + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + } + + for (let i = 0; i < 4; i++) { + let href = `http://example.com/hi${i}`; + await PlacesTestUtils.addBookmarkWithDetails({ + uri: href, + title: `${SEARCH_STRING} high frecency ${i}`, + }); + } + + // Do an unrestricted search to make sure everything appears in it, including + // the visit and bookmark. + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS), + ...makeRemoteSuggestionResults(context), + makeBookmarkResult(context, { + uri: "http://example.com/hi3", + title: `${SEARCH_STRING} high frecency 3`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi2", + title: `${SEARCH_STRING} high frecency 2`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi1", + title: `${SEARCH_STRING} high frecency 1`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi0", + title: `${SEARCH_STRING} high frecency 0`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo4", + title: `${SEARCH_STRING} low frecency 4`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo3", + title: `${SEARCH_STRING} low frecency 3`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo2", + title: `${SEARCH_STRING} low frecency 2`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo1", + title: `${SEARCH_STRING} low frecency 1`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo0", + title: `${SEARCH_STRING} low frecency 0`, + }), + ], + }); + + // Change the mixup. + setResultGroups([ + // 1 suggestion + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + // 5 general + { + maxResultCount: 5, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + // 1 suggestion + { + maxResultCount: 1, + children: [ + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ], + }, + // remaining general + { group: UrlbarUtils.RESULT_GROUP.GENERAL }, + // remaining suggestions + { group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY }, + { group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION }, + ]); + + // Do an unrestricted search to make sure everything appears in it, including + // the visits and bookmarks. + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, 1), + makeBookmarkResult(context, { + uri: "http://example.com/hi3", + title: `${SEARCH_STRING} high frecency 3`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi2", + title: `${SEARCH_STRING} high frecency 2`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi1", + title: `${SEARCH_STRING} high frecency 1`, + }), + makeBookmarkResult(context, { + uri: "http://example.com/hi0", + title: `${SEARCH_STRING} high frecency 0`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo4", + title: `${SEARCH_STRING} low frecency 4`, + }), + ...makeFormHistoryResults(context, 2).slice(1), + makeVisitResult(context, { + uri: "http://example.com/lo3", + title: `${SEARCH_STRING} low frecency 3`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo2", + title: `${SEARCH_STRING} low frecency 2`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo1", + title: `${SEARCH_STRING} low frecency 1`, + }), + makeVisitResult(context, { + uri: "http://example.com/lo0", + title: `${SEARCH_STRING} low frecency 0`, + }), + ...makeFormHistoryResults(context, MAX_RESULTS).slice(2), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.clearUserPref(MAX_RICH_RESULTS_PREF); + await cleanUpSuggestions(); +}); + +add_task(async function prohibit_suggestions() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + true + ); + registerCleanupFunction(() => { + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + }); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${SEARCH_STRING}/`, + fallbackTitle: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 2), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + // When using multiple words, we should still get suggestions: + let query = `${SEARCH_STRING} world`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { suggestionPrefix: query }), + ], + }); + + // Clear the whitelist for SEARCH_STRING and try preferring DNS for any single + // word instead: + Services.prefs.setBoolPref( + `browser.fixup.domainwhitelist.${SEARCH_STRING}`, + false + ); + Services.prefs.setBoolPref("browser.fixup.dns_first_for_single_words", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words"); + }); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: `http://${SEARCH_STRING}/`, + fallbackTitle: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 2), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + context = createContext("somethingelse", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://somethingelse/", + fallbackTitle: "http://somethingelse/", + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: false, + }), + ], + }); + + // When using multiple words, we should still get suggestions: + query = `${SEARCH_STRING} world`; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context, { suggestionPrefix: query }), + ], + }); + + Services.prefs.clearUserPref("browser.fixup.dns_first_for_single_words"); + + context = createContext("http://1.2.3.4/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://1.2.3.4/", + fallbackTitle: "http://1.2.3.4/", + iconUri: "page-icon:http://1.2.3.4/", + heuristic: true, + }), + ], + }); + + context = createContext("[2001::1]:30", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://[2001::1]:30/", + fallbackTitle: "http://[2001::1]:30/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("user:pass@test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://user:pass@test/", + fallbackTitle: "http://user:pass@test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("data:text/plain,Content", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "data:text/plain,Content", + fallbackTitle: "data:text/plain,Content", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function uri_like_queries() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // We should not fetch any suggestions for an actual URL. + let query = "mozilla.org"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + fallbackTitle: `http://${query}/`, + uri: `http://${query}/`, + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { query, engineName: SUGGESTIONS_ENGINE_NAME }), + ], + }); + + // We should also not fetch suggestions for a partially-typed URL. + query = "mozilla.o"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + // Now trying queries that could be confused for URLs. They should return + // results. + const uriLikeQueries = [ + "mozilla.org is a great website", + "I like mozilla.org", + "a/b testing", + "he/him", + "Google vs.", + "5.8 cm", + ]; + for (query of uriLikeQueries) { + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: query, + }), + ], + }); + } + + await cleanUpSuggestions(); +}); + +add_task(async function avoid_remote_url_suggestions_1() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + setSuggestionsFn(searchStr => { + let suffixes = [".com", "/test", ":1]", "@test", ". com"]; + return suffixes.map(s => searchStr + s); + }); + + const query = "test"; + + await UrlbarTestUtils.formHistory.add([`${query}.com`]); + + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: `${query}.com`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: `${query}. com`, + }), + ], + }); + + await cleanUpSuggestions(); + await UrlbarTestUtils.formHistory.remove([`${query}.com`]); +}); + +add_task(async function avoid_remote_url_suggestions_2() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + + setSuggestionsFn(searchStr => { + let suffixes = ["ed", "eds"]; + return suffixes.map(s => searchStr + s); + }); + + let context = createContext("htt", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "htted", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "htteds", + }), + ], + }); + + context = createContext("ftp", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "ftped", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "ftpeds", + }), + ], + }); + + context = createContext("http", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httped", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpeds", + }), + ], + }); + + context = createContext("http:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpsed", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpseds", + }), + ], + }); + + context = createContext("https:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("httpd", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpded", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "httpdeds", + }), + ], + }); + + // Check FTP disabled + context = createContext("ftp:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("ftp://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "ftp://test/", + fallbackTitle: "ftp://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("http://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("https://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("http://www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www/", + fallbackTitle: "http://www/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("https://www", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://www/", + fallbackTitle: "https://www/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://test/", + fallbackTitle: "http://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("https://test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "https://test/", + fallbackTitle: "https://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://www.test", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.test/", + fallbackTitle: "http://www.test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http://www.test.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www.test.com/", + fallbackTitle: "http://www.test.com/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("file", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "fileed", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "fileeds", + }), + ], + }); + + context = createContext("file:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("file:///Users", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "file:///Users", + fallbackTitle: "file:///Users", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("moz-test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("moz+test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + context = createContext("about", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "abouted", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: "abouteds", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function restrict_remote_suggestions_after_no_results() { + // We don't fetch remote suggestions if a query with a length over + // maxCharsForSearchSuggestions returns 0 results. We set it to 4 here to + // avoid constructing a 100+ character string. + Services.prefs.setIntPref("browser.urlbar.maxCharsForSearchSuggestions", 4); + setSuggestionsFn(searchStr => { + return []; + }); + + const query = SEARCH_STRING.substring(0, SEARCH_STRING.length - 1); + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + ], + }); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 1), + // Because the previous search returned no suggestions, we will not fetch + // remote suggestions for this query that is just a longer version of the + // previous query. + ], + }); + + // Do one more search before resetting maxCharsForSearchSuggestions to reset + // the search suggestion provider's _lastLowResultsSearchSuggestion property. + // Otherwise it will be stuck at SEARCH_STRING, which interferes with + // subsequent tests. + context = createContext("not the search string", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.maxCharsForSearchSuggestions"); + + await cleanUpSuggestions(); +}); + +add_task(async function formHistory() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + + // `maxHistoricalSearchSuggestions` is no longer treated as a max count but as + // a boolean: If it's zero, then the user has opted out of form history so we + // shouldn't include any at all; if it's non-zero, then we include form + // history according to the limits specified in the muxer's result groups. + + // zero => no form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 0); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + + // non-zero => allow form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 1); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + // non-zero => allow form history + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 2); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + ...makeRemoteSuggestionResults(context), + ], + }); + + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); + + // Do a search for exactly the suggestion of the first form history result. + // The heuristic's query should be the suggestion; the first form history + // result should not be included since it dupes the heuristic; the other form + // history results should not be included since they don't match; and both + // remote suggestions should be included. + let firstSuggestion = makeFormHistoryResults(context, 1)[0].payload + .suggestion; + context = createContext(firstSuggestion, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstSuggestion, + }), + ], + }); + + // Do the same search but in uppercase with a trailing space. We should get + // the same results, i.e., the form history result dupes the trimmed search + // string so it shouldn't be included. + let query = firstSuggestion.toUpperCase() + " "; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query, + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstSuggestion.toUpperCase(), + }), + ], + }); + + // Add a form history entry that dupes the first remote suggestion and do a + // search that triggers both. The form history should be included but the + // remote suggestion should not since it dupes the form history. + let suggestionPrefix = "dupe"; + let dupeSuggestion = makeRemoteSuggestionResults(context, { + suggestionPrefix, + })[0].payload.suggestion; + Assert.ok(dupeSuggestion, "Sanity check: dupeSuggestion is defined"); + await UrlbarTestUtils.formHistory.add([dupeSuggestion]); + + context = createContext(suggestionPrefix, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: dupeSuggestion, + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { suggestionPrefix }).slice(1), + ], + }); + + await UrlbarTestUtils.formHistory.remove([dupeSuggestion]); + + // Add these form history strings to use below. + let formHistoryStrings = ["foo", "FOO ", "foobar", "fooquux"]; + await UrlbarTestUtils.formHistory.add(formHistoryStrings); + + // Search for "foo". "foo" and "FOO " shouldn't be included since they dupe + // the heuristic. Both "foobar" and "fooquux" should be included even though + // the max form history count is only two and there are four matching form + // history results (including the discarded "foo" and "FOO "). + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Add a visit that matches "foo" and will autofill so that the heuristic is + // not a search result. Now the "foo" and "foobar" form history should be + // included. The "foo" remote suggestion should not be included since it + // dupes the "foo" form history. + await PlacesTestUtils.addVisits("http://foo.example.com/"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://foo.example.com/", + title: "test visit for http://foo.example.com/", + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + await PlacesUtils.history.clear(); + + // Add SERPs for "foobar", "fooBAR ", and "food", and search for "foo". The + // "foo" form history should be excluded since it dupes the heuristic; the + // "foobar" and "fooquux" form history should be included; the "food" SERP + // should be included since it doesn't dupe either form history result; and + // the "foobar" and "fooBAR " SERPs depend on the result groups, see below. + let engine = await Services.search.getDefault(); + let serpURLs = ["foobar", "fooBAR ", "food"].map( + term => UrlbarUtils.getSearchQueryUrl(engine, term)[0] + ); + await PlacesTestUtils.addVisits(serpURLs); + + // First set showSearchSuggestionsFirst = false so that general results appear + // before suggestions, which means that the muxer visits the "foobar" and + // "fooBAR " SERPs before visiting the "foobar" form history, and so it + // doesn't see that these two SERPs dupe the form history. They are therefore + // included. + Services.prefs.setBoolPref(SHOW_SEARCH_SUGGESTIONS_FIRST_PREF, false); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=fooBAR+`, + title: `test visit for http://localhost:${port}/search?q=fooBAR+`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=foobar`, + title: `test visit for http://localhost:${port}/search?q=foobar`, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Now clear showSearchSuggestionsFirst so that suggestions appear before + // general results. Now the muxer will see that the "foobar" and "fooBAR " + // SERPs dupe the "foobar" form history, so it will exclude them. + Services.prefs.clearUserPref(SHOW_SEARCH_SUGGESTIONS_FIRST_PREF); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + ], + }); + + await UrlbarTestUtils.formHistory.remove(formHistoryStrings); + + await cleanUpSuggestions(); + await PlacesUtils.history.clear(); +}); + +// When the heuristic is hidden, search results that match the heuristic should +// be included and not deduped. +add_task(async function hideHeuristic() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...makeFormHistoryResults(context, MAX_RESULTS - 3), + makeSearchResult(context, { + query: SEARCH_STRING, + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: SEARCH_STRING, + }), + ...makeRemoteSuggestionResults(context), + ], + }); + await cleanUpSuggestions(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); + +// When the heuristic is hidden, form history results that match the heuristic +// should be included and not deduped. +add_task(async function hideHeuristic_formHistory() { + UrlbarPrefs.set("experimental.hideHeuristic", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + + // Search for exactly the suggestion of the first form history result. + // Expected results: + // + // * First form history should be included even though it dupes the heuristic + // * Other form history should not be included because they don't match the + // search string + // * The first remote suggestion that just echoes the search string should not + // be included because it dupes the first form history + // * The remaining remote suggestions should be included because they don't + // dupe anything + let context = createContext(SEARCH_STRING, { isPrivate: false }); + let firstFormHistory = makeFormHistoryResults(context, 1)[0]; + context = createContext(firstFormHistory.payload.suggestion, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + firstFormHistory, + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: firstFormHistory.payload.suggestion, + }), + ], + }); + + // Add these form history strings to use below. + let formHistoryStrings = ["foo", "FOO ", "foobar", "fooquux"]; + await UrlbarTestUtils.formHistory.add(formHistoryStrings); + + // Search for "foo". Expected results: + // + // * "foo" form history should be included even though it dupes the heuristic + // * "FOO " form history should not be included because it dupes the "foo" + // form history + // * "foobar" and "fooqux" form history should be included because they don't + // dupe anything + // * "foo" remote suggestion should not be included because it dupes the "foo" + // form history + // * "foo foo" and "foo bar" remote suggestions should be included because + // they don't dupe anything + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Add SERPs for "foo" and "food", and search for "foo". Expected results: + // + // * "foo" form history should be included even though it dupes the heuristic + // * "foobar" and "fooqux" form history should be included because they don't + // dupe anything + // * "foo" SERP depends on `showSearchSuggestionsFirst`, see below + // * "food" SERP should be include because it doesn't dupe anything + // * "foo" remote suggestion should not be included because it dupes the "foo" + // form history + // * "foo foo" and "foo bar" remote suggestions should be included because + // they don't dupe anything + let engine = await Services.search.getDefault(); + let serpURLs = ["foo", "food"].map( + term => UrlbarUtils.getSearchQueryUrl(engine, term)[0] + ); + await PlacesTestUtils.addVisits(serpURLs); + + // With `showSearchSuggestionsFirst = false` so that general results appear + // before suggestions, the muxer visits the "foo" (and "food") SERPs before + // visiting the "foo" form history, and so it doesn't see that the "foo" SERP + // dupes the form history. The SERP is therefore included. + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=foo`, + title: `test visit for http://localhost:${port}/search?q=foo`, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + ], + }); + + // Now clear `showSearchSuggestionsFirst` so that suggestions appear before + // general results. Now the muxer will see that the "foo" SERP dupes the "foo" + // form history, so it will exclude it. + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: SUGGESTIONS_ENGINE_NAME, + }), + ...makeRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=food`, + title: `test visit for http://localhost:${port}/search?q=food`, + }), + ], + }); + + await UrlbarTestUtils.formHistory.remove(formHistoryStrings); + + await cleanUpSuggestions(); + UrlbarPrefs.clear("experimental.hideHeuristic"); +}); diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js b/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js new file mode 100644 index 0000000000..1f70a421fa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js @@ -0,0 +1,364 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that an engine with suggestions works with our alias autocomplete + * behavior. + */ + +const DEFAULT_ENGINE_NAME = "TestDefaultEngine"; +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const HISTORY_TITLE = "fire"; + +// We make sure that aliases and search terms are correctly recognized when they +// are separated by each of these different types of spaces and combinations of +// spaces. U+3000 is the ideographic space in CJK and is commonly used by CJK +// speakers. +const TEST_SPACES = [" ", "\u3000", " \u3000", "\u3000 "]; + +let engine; +let port; + +add_task(async function setup() { + engine = await addTestSuggestionsEngine(); + port = engine.getSubmission("").uri.port; + + // Set a mock engine as the default so we don't hit the network below when we + // do searches that return the default engine heuristic result. + await SearchTestUtils.installSearchExtension( + { + name: DEFAULT_ENGINE_NAME, + search_url: "https://my.search.com/", + }, + { setAsDefault: true } + ); + + // History matches should not appear with @aliases, so this visit should not + // appear when searching with @aliases below. + await PlacesTestUtils.addVisits({ + uri: engine.searchForm, + title: HISTORY_TITLE, + }); +}); + +// A non-token alias without a trailing space shouldn't be recognized as a +// keyword. It should be treated as part of the search string. +add_task(async function nonTokenAlias_noTrailingSpace() { + Services.prefs.setBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ); + + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + let context = createContext(alias, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: DEFAULT_ENGINE_NAME, + query: alias, + heuristic: true, + }), + ], + }); + Services.prefs.clearUserPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); +}); + +// A non-token alias with a trailing space should be recognized as a keyword, +// and the history result should be included. +add_task(async function nonTokenAlias_trailingSpace() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + + for (let isPrivate of [false, true]) { + for (let spaces of TEST_SPACES) { + info( + "Testing: " + JSON.stringify({ isPrivate, spaces: codePoints(spaces) }) + ); + let context = createContext(alias + spaces, { isPrivate }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "", + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } + } +}); + +// Search for "alias HISTORY_TITLE" with a non-token alias in a non-private +// context. The remote suggestions and history result should be shown. +add_task(async function nonTokenAlias_history_nonPrivate() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} foo`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} bar`, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } +}); + +// Search for "alias HISTORY_TITLE" with a non-token alias in a private context. +// The history result should be shown, but not the remote suggestions. +add_task(async function nonTokenAlias_history_private() { + let alias = "moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: true, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://localhost:${port}/search?q=`, + title: HISTORY_TITLE, + }), + ], + }); + } +}); + +// A token alias without a trailing space should be autofilled with a trailing +// space and recognized as a keyword with a keyword offer. +add_task(async function tokenAlias_noTrailingSpace() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let isPrivate of [false, true]) { + let context = createContext(alias, { isPrivate }); + await check_results({ + context, + autofilled: alias + " ", + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + providesSearchMode: true, + query: "", + heuristic: false, + }), + ], + }); + } +}); + +// A token alias with a trailing space should be recognized as a keyword without +// a keyword offer. +add_task(async function tokenAlias_trailingSpace() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let isPrivate of [false, true]) { + for (let spaces of TEST_SPACES) { + info( + "Testing: " + JSON.stringify({ isPrivate, spaces: codePoints(spaces) }) + ); + let context = createContext(alias + spaces, { isPrivate }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "", + heuristic: true, + }), + ], + }); + } + } +}); + +// Search for "alias HISTORY_TITLE" with a token alias in a non-private context. +// The remote suggestions should be shown, but not the history result. +add_task(async function tokenAlias_history_nonPrivate() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} foo`, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + suggestion: `${HISTORY_TITLE} bar`, + }), + ], + }); + } +}); + +// Search for "alias HISTORY_TITLE" with a token alias in a private context. +// Neither the history result nor the remote suggestions should be shown. +add_task(async function tokenAlias_history_private() { + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + HISTORY_TITLE, { + isPrivate: true, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: HISTORY_TITLE, + heuristic: true, + }), + ], + }); + } +}); + +// Even when they're disabled, suggestions should still be returned when using a +// token alias in a non-private context. +add_task(async function suggestionsDisabled_nonPrivate() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + "term", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + suggestion: "term foo", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + suggestion: "term bar", + }), + ], + }); + } + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); +}); + +// Suggestions should not be returned when using a token alias in a private +// context. +add_task(async function suggestionsDisabled_private() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); + let alias = "@moz"; + engine.alias = alias; + Assert.equal(engine.alias, alias); + for (let spaces of TEST_SPACES) { + info("Testing: " + JSON.stringify({ spaces: codePoints(spaces) })); + let context = createContext(alias + spaces + "term", { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + alias, + query: "term", + heuristic: true, + }), + ], + }); + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + } +}); + +/** + * Returns an array of code points in the given string. Each code point is + * returned as a hexidecimal string. + * + * @param {string} str + * The code points of this string will be returned. + * @returns {Array} + * Array of code points in the string, where each is a hexidecimal string. + */ +function codePoints(str) { + return str.split("").map(s => s.charCodeAt(0).toString(16)); +} diff --git a/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js b/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js new file mode 100644 index 0000000000..da43463a69 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js @@ -0,0 +1,379 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that tailed search engine suggestions are returned by + * UrlbarProviderSearchSuggestions when available. + */ + +const SUGGEST_ENABLED_PREF = "browser.search.suggest.enabled"; +const PRIVATE_SEARCH_PREF = "browser.search.separatePrivateDefault.ui.enabled"; +const TAIL_SUGGESTIONS_PREF = "browser.urlbar.richSuggestions.tail"; + +var suggestionsFn; +var previousSuggestionsFn; + +/** + * Set the current suggestion funciton. + * + * @param {Function} fn + * A function that that a search string and returns an array of strings that + * will be used as search suggestions. + * Note: `fn` should return > 1 suggestion in most cases. Otherwise, you may + * encounter unexceptede behaviour with UrlbarProviderSuggestion's + * _lastLowResultsSearchSuggestion safeguard. + */ +function setSuggestionsFn(fn) { + previousSuggestionsFn = suggestionsFn; + suggestionsFn = fn; +} + +async function cleanup() { + await PlacesUtils.bookmarks.eraseEverything(); + await PlacesUtils.history.clear(); +} + +async function cleanUpSuggestions() { + await cleanup(); + if (previousSuggestionsFn) { + suggestionsFn = previousSuggestionsFn; + previousSuggestionsFn = null; + } +} + +add_task(async function setup() { + let engine = await addTestTailSuggestionsEngine(searchStr => { + return suggestionsFn(searchStr); + }); + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + suffixes.map(s => searchStr + s.slice(1)), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": suffixes.map(s => ({ + mp: "… ", + t: s, + })), + }, + ]; + }); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref(TAIL_SUGGESTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + }); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, true); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, true); +}); + +/** + * Tests that non-tail suggestion providers still return results correctly when + * the tailSuggestions pref is enabled. + */ +add_task(async function normal_suggestions_provider() { + let engine = await addTestSuggestionsEngine(); + let tailEngine = await Services.search.getDefault(); + Services.search.setDefault(engine, Ci.nsISearchService.CHANGE_REASON_UNKNOWN); + + const query = "hello world"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: query + " foo", + }), + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + suggestion: query + " bar", + }), + ], + }); + + Services.search.setDefault( + tailEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns only tail suggestions. + */ +add_task(async function basic_tail() { + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "unisia", + tail: "tunisia", + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns both normal and tail suggestions. + * Only normal results should be shown. + */ +add_task(async function mixed_suggestions() { + // When normal suggestions are mixed with tail suggestions, they appear at the + // correct position in the google:suggestdetail array as empty objects. + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + ["what is the time today texas"].concat( + suffixes.map(s => searchStr + s.slice(1)) + ), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [{}].concat( + suffixes.map(s => ({ + mp: "… ", + t: s, + })) + ), + }, + ]; + }); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: "what is the time today texas", + tail: undefined, + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a suggestions provider that returns both normal and tail suggestions, + * with tail suggestions listed before normal suggestions. In the real world + * we don't expect that to happen, but we should handle it by showing only the + * normal suggestions. + */ +add_task(async function mixed_suggestions_tail_first() { + setSuggestionsFn(searchStr => { + let suffixes = ["toronto", "tunisia"]; + return [ + "what time is it in t", + suffixes + .map(s => searchStr + s.slice(1)) + .concat(["what is the time today texas"]), + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": suffixes + .map(s => ({ + mp: "… ", + t: s, + })) + .concat([{}]), + }, + ]; + }); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: "what is the time today texas", + tail: undefined, + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests a search that returns history results, bookmark results and tail + * suggestions. Only the history and bookmark results should be shown. + */ +add_task(async function mixed_results() { + await PlacesTestUtils.addVisits([ + { + uri: Services.io.newURI("http://example.com/1"), + title: "what time is", + }, + ]); + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: "http://example.com/2", + title: "what time is", + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Tail suggestions should not be shown. + const query = "what time is"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://example.com/2", + title: "what time is", + }), + makeVisitResult(context, { + uri: "http://example.com/1", + title: "what time is", + }), + ], + }); + + // Once we make the query specific enough to exclude the history and bookmark + // results, we should show tail suggestions. + const tQuery = "what time is it in t"; + context = createContext(tQuery, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: tQuery + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: tQuery + "unisia", + tail: "tunisia", + }), + ], + }); + + await cleanUpSuggestions(); +}); + +/** + * Tests that tail suggestions are deduped if their full-text form is a dupe of + * a local search suggestion. Remaining tail suggestions should also not be + * shown since we do not mix tail and non-tail suggestions. + */ +add_task(async function dedupe_local() { + Services.prefs.setIntPref("browser.urlbar.maxHistoricalSearchSuggestions", 1); + await UrlbarTestUtils.formHistory.add(["what time is it in toronto"]); + + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeFormHistoryResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + }), + ], + }); + + Services.prefs.clearUserPref("browser.urlbar.maxHistoricalSearchSuggestions"); + await cleanUpSuggestions(); +}); + +/** + * Tests that the correct number of suggestion results are displayed if + * maxResults is limited, even when tail suggestions are returned. + */ +add_task(async function limit_results() { + await UrlbarTestUtils.formHistory.clear(); + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + context.maxResults = 2; + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + suggestion: query + "oronto", + tail: "toronto", + }), + ], + }); + await cleanUpSuggestions(); +}); + +/** + * Tests that tail suggestions are hidden if the pref is disabled. + */ +add_task(async function disable_pref() { + let oldPrefValue = Services.prefs.getBoolPref(TAIL_SUGGESTIONS_PREF); + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, false); + const query = "what time is it in t"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: TAIL_SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ], + }); + + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, oldPrefValue); + await cleanUpSuggestions(); +}); diff --git a/browser/components/urlbar/tests/unit/test_special_search.js b/browser/components/urlbar/tests/unit/test_special_search.js new file mode 100644 index 0000000000..863196909a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_special_search.js @@ -0,0 +1,543 @@ +/* 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 bug 395161 that allows special searches that restrict results to + * history/bookmark/tagged items and title/url matches. + * + * Test 485122 by making sure results don't have tags when restricting result + * to just history either by default behavior or dynamic query restrict. + */ + +testEngine_setup(); + +function setSuggestPrefsToFalse() { + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); +} + +const TRANSITION_TYPED = PlacesUtils.history.TRANSITION_TYPED; + +add_task(async function test_special_searches() { + let uri1 = Services.io.newURI("http://url/"); + let uri2 = Services.io.newURI("http://url/2"); + let uri3 = Services.io.newURI("http://foo.bar/"); + let uri4 = Services.io.newURI("http://foo.bar/2"); + let uri5 = Services.io.newURI("http://url/star"); + let uri6 = Services.io.newURI("http://url/star/2"); + let uri7 = Services.io.newURI("http://foo.bar/star"); + let uri8 = Services.io.newURI("http://foo.bar/star/2"); + let uri9 = Services.io.newURI("http://url/tag"); + let uri10 = Services.io.newURI("http://url/tag/2"); + let uri11 = Services.io.newURI("http://foo.bar/tag"); + let uri12 = Services.io.newURI("http://foo.bar/tag/2"); + await PlacesTestUtils.addVisits([ + { uri: uri11, title: "title", transition: TRANSITION_TYPED }, + { uri: uri6, title: "foo.bar" }, + { uri: uri4, title: "foo.bar", transition: TRANSITION_TYPED }, + { uri: uri3, title: "title" }, + { uri: uri2, title: "foo.bar" }, + { uri: uri1, title: "title", transition: TRANSITION_TYPED }, + ]); + + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri12, + title: "foo.bar", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri11, + title: "title", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri10, + title: "foo.bar", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri9, + title: "title", + tags: ["foo.bar"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri8, title: "foo.bar" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri7, title: "title" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri6, title: "foo.bar" }); + await PlacesTestUtils.addBookmarkWithDetails({ uri: uri5, title: "title" }); + + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + // Order of frecency when not restricting, descending: + // uri11 + // uri1 + // uri4 + // uri6 + // uri5 + // uri7 + // uri8 + // uri9 + // uri10 + // uri12 + // uri2 + // uri3 + + // Test restricting searches. + + info("History restrict"); + let context = createContext(UrlbarTokenizer.RESTRICT.HISTORY, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri1.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("Star restrict"); + context = createContext(UrlbarTokenizer.RESTRICT.BOOKMARK, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri5.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + }), + ], + }); + + info("Tag restrict"); + context = createContext(UrlbarTokenizer.RESTRICT.TAG, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + }), + ], + }); + + info("Special as first word"); + context = createContext(`${UrlbarTokenizer.RESTRICT.HISTORY} foo bar`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "foo bar", + alias: UrlbarTokenizer.RESTRICT.HISTORY, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("Special as last word"); + context = createContext(`foo bar ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + // Test restricting and matching searches with a term. + + info(`foo ${UrlbarTokenizer.RESTRICT.HISTORY} -> history`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.HISTORY}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.BOOKMARK} -> is star`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.TITLE} -> in title`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.TITLE}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri9.spec, title: "title" }), + makeVisitResult(context, { uri: uri10.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri12.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.URL} -> in url`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.URL}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri12.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info(`foo ${UrlbarTokenizer.RESTRICT.TAG} -> is tag`); + context = createContext(`foo ${UrlbarTokenizer.RESTRICT.TAG}`, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + // Test conflicting restrictions. + + info( + `conflict ${UrlbarTokenizer.RESTRICT.TITLE} ${UrlbarTokenizer.RESTRICT.URL} -> url wins` + ); + await PlacesTestUtils.addVisits([ + { + uri: `http://conflict.com/${UrlbarTokenizer.RESTRICT.TITLE}`, + title: "test", + }, + { + uri: "http://conflict.com/", + title: `test${UrlbarTokenizer.RESTRICT.TITLE}`, + }, + ]); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.TITLE} ${UrlbarTokenizer.RESTRICT.URL}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: `http://conflict.com/${UrlbarTokenizer.RESTRICT.TITLE}`, + title: "test", + }), + ], + }); + + info( + `conflict ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.BOOKMARK} -> bookmark wins` + ); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://bookmark.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.HISTORY}`, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://bookmark.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.HISTORY}`, + }), + ], + }); + + info( + `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK} ${UrlbarTokenizer.RESTRICT.TAG} -> tag wins` + ); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + tags: ["one"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://nontag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + context = createContext( + `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK} ${UrlbarTokenizer.RESTRICT.TAG}`, + { isPrivate: false } + ); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag.conflict.com/", + title: `conflict ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + }), + ], + }); + + // Disable autoFill for the next tests, see test_autoFill_default_behavior.js + // for specific tests. + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + + // Test default usage by setting certain browser.urlbar.suggest.* prefs + info("foo -> default history"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri11.spec, title: "title" }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + + info("foo -> default history, is star"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + // The purpose of this test is to verify what is being sent by ProviderPlaces. + // It will send 10 results, but the heuristic result pushes the last result + // out of the panel. We set maxRichResults to a high value to test the full + // output of ProviderPlaces. + Services.prefs.setIntPref("browser.urlbar.maxRichResults", 20); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeVisitResult(context, { uri: uri4.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeVisitResult(context, { uri: uri2.spec, title: "foo.bar" }), + makeVisitResult(context, { uri: uri3.spec, title: "title" }), + ], + }); + Services.prefs.clearUserPref("browser.urlbar.maxRichResults"); + + info("foo -> is star"); + setSuggestPrefsToFalse(); + Services.prefs.setBoolPref("browser.urlbar.suggest.history", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri11.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { uri: uri6.spec, title: "foo.bar" }), + makeBookmarkResult(context, { uri: uri7.spec, title: "title" }), + makeBookmarkResult(context, { uri: uri8.spec, title: "foo.bar" }), + makeBookmarkResult(context, { + uri: uri9.spec, + title: "title", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri10.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + makeBookmarkResult(context, { + uri: uri12.spec, + title: "foo.bar", + tags: ["foo.bar"], + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_suggestedIndex.js b/browser/components/urlbar/tests/unit/test_suggestedIndex.js new file mode 100644 index 0000000000..f2f7fcc0d6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_suggestedIndex.js @@ -0,0 +1,562 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests results with suggestedIndex and resultSpan. + +"use strict"; + +const MAX_RESULTS = 10; + +add_task(async function suggestedIndex() { + // Initialize maxRichResults for sanity. + UrlbarPrefs.set("maxRichResults", MAX_RESULTS); + + let tests = [ + // no result spans > 1 + { + desc: "{ suggestedIndex: 0 }", + suggestedIndexes: [0], + expected: indexes([10, 1], [0, 9]), + }, + { + desc: "{ suggestedIndex: 1 }", + suggestedIndexes: [1], + expected: indexes([0, 1], [10, 1], [1, 8]), + }, + { + desc: "{ suggestedIndex: -1 }", + suggestedIndexes: [-1], + expected: indexes([0, 9], [10, 1]), + }, + { + desc: "{ suggestedIndex: -2 }", + suggestedIndexes: [-2], + expected: indexes([0, 8], [10, 1], [8, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + expected: indexes([10, 1], [0, 8], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + expected: indexes([0, 1], [10, 1], [1, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1], [7, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, resultCount < max", + suggestedIndexes: [0], + resultCount: 5, + expected: indexes([5, 1], [0, 5]), + }, + { + desc: "{ suggestedIndex: 1 }, resultCount < max", + suggestedIndexes: [1], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4]), + }, + { + desc: "{ suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [-1], + resultCount: 5, + expected: indexes([0, 5], [5, 1]), + }, + { + desc: "{ suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [-2], + resultCount: 5, + expected: indexes([0, 4], [5, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [0, -1], + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [1, -1], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [0, -2], + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [1, -2], + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 3], [6, 1], [4, 1]), + }, + + // one suggestedIndex with result span > 1 + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }", + suggestedIndexes: [0], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }", + suggestedIndexes: [0], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 7]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }", + suggestedIndexes: [1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "suggestedIndex: 1, resultSpan:: 3 }", + suggestedIndexes: [1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 6]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan 2 }", + suggestedIndexes: [-1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [-1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 7], [10, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -1 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 11: 2 }, + expected: indexes([10, 1], [0, 7], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [0, -1], + spansByIndex: { 11: 3 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -1 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [1, -1], + spansByIndex: { 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 11: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [0, -2], + spansByIndex: { 11: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [1, -2], + spansByIndex: { 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4]), + }, + { + desc: "{ suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [-1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 5], [5, 1]), + }, + { + desc: "{ suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [-2], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 4], [5, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1 }, resultCount < max", + suggestedIndexes: [1, -1], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 5: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0 }, { suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + + // two suggestedIndexes with result span > 1 + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([10, 1], [0, 6], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([10, 1], [0, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [0, -1], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([10, 1], [0, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 5], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -1, resultSpan: 2 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 3 }", + suggestedIndexes: [1, -1], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([10, 1], [0, 5], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 3 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([10, 1], [0, 4], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [0, -2], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([10, 1], [0, 4], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 4], [11, 1], [5, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 3 }, { suggestedIndex: -2, resultSpan: 2 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 3, 11: 2 }, + expected: indexes([0, 1], [10, 1], [1, 3], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 3 }", + suggestedIndexes: [1, -2], + spansByIndex: { 10: 2, 11: 3 }, + expected: indexes([0, 1], [10, 1], [1, 3], [11, 1], [4, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -1], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 5], [6, 1]), + }, + { + desc: "{ suggestedIndex: 1, resultSpan: 2 }, { suggestedIndex: -1, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [1, -1], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([0, 1], [5, 1], [1, 4], [6, 1]), + }, + { + desc: "{ suggestedIndex: 0, resultSpan: 2 }, { suggestedIndex: -2, resultSpan: 2 }, resultCount < max", + suggestedIndexes: [0, -2], + spansByIndex: { 5: 2, 6: 2 }, + resultCount: 5, + expected: indexes([5, 1], [0, 4], [6, 1], [4, 1]), + }, + + // one suggestedIndex plus other result with resultSpan > 1 + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } A", + suggestedIndexes: [0], + spansByIndex: { 0: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } B", + suggestedIndexes: [0], + spansByIndex: { 8: 2 }, + expected: indexes([10, 1], [0, 8]), + }, + { + desc: "{ suggestedIndex: 0 }, { resultSpan: 2 } C", + suggestedIndexes: [0], + spansByIndex: { 9: 2 }, + expected: indexes([10, 1], [0, 9]), + }, + { + desc: "{ suggestedIndex: 1 }, { resultSpan: 2 } A", + suggestedIndexes: [1], + spansByIndex: { 0: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "{ suggestedIndex: 1 }, { resultSpan: 2 } B", + suggestedIndexes: [1], + spansByIndex: { 8: 2 }, + expected: indexes([0, 1], [10, 1], [1, 7]), + }, + { + desc: "{ suggestedIndex: -1 }, { resultSpan: 2 }", + suggestedIndexes: [-1], + spansByIndex: { 0: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + { + desc: "{ suggestedIndex: -2 }, { resultSpan: 2 }", + suggestedIndexes: [-2], + spansByIndex: { 0: 2 }, + expected: indexes([0, 7], [10, 1], [7, 1]), + }, + + // miscellaneous + { + desc: "no suggestedIndex, last result has resultSpan = 2", + suggestedIndexes: [], + spansByIndex: { 9: 2 }, + expected: indexes([0, 9]), + }, + { + desc: "{ suggestedIndex: -1 }, last result has resultSpan = 2", + suggestedIndexes: [-1], + spansByIndex: { 9: 2 }, + expected: indexes([0, 9], [10, 1]), + }, + { + desc: "no suggestedIndex, index 8 result has resultSpan = 2", + suggestedIndexes: [], + spansByIndex: { 8: 2 }, + expected: indexes([0, 9]), + }, + { + desc: "{ suggestedIndex: -1 }, index 8 result has resultSpan = 2", + suggestedIndexes: [-1], + spansByIndex: { 8: 2 }, + expected: indexes([0, 8], [10, 1]), + }, + ]; + + for (let test of tests) { + info("Running test: " + JSON.stringify(test)); + await doSuggestedIndexTest(test); + } +}); + +/** + * Sets up a provider with some results with suggested indexes and result spans, + * performs a search, and then checks the results. + * + * @param {object} options + * Options for the test. + * @param {Array} options.suggestedIndexes + * For each of the indexes in this array, a new result with the given + * suggestedIndex will be returned by the provider. + * @param {Array} options.expected + * The indexes of the expected results within the array of results returned by + * the provider. + * @param {object} [options.spansByIndex] + * Maps indexes within the array of results returned by the provider to result + * spans to set on those results. + * @param {number} [options.resultCount] + * Aside from the results with suggested indexes, this is the number of + * results that the provider will return. + */ +async function doSuggestedIndexTest({ + suggestedIndexes, + expected, + spansByIndex = {}, + resultCount = MAX_RESULTS, +}) { + // Make resultCount history results. + let results = []; + for (let i = 0; i < resultCount; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/" + i, + } + ) + ); + } + + // Make the suggested-index results. + for (let suggestedIndex of suggestedIndexes) { + results.push( + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + url: "http://example.com/si " + suggestedIndex, + } + ), + { suggestedIndex } + ) + ); + } + + // Set resultSpan on each result as indicated by spansByIndex. + for (let [index, span] of Object.entries(spansByIndex)) { + results[index].resultSpan = span; + } + + // Set up the provider, etc. + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + let controller = UrlbarTestUtils.newMockController(); + + // Finally, search and check the results. + let expectedResults = expected.map(i => results[i]); + await UrlbarProvidersManager.startQuery(context, controller); + Assert.deepEqual(context.results, expectedResults); +} + +/** + * Helper that generates an array of indexes. Pass in [index, length] tuples. + * Each tuple will produce the indexes starting from `index` to `index + length` + * (not including the index at `index + length`). + * + * Examples: + * + * indexes([0, 5]) => [0, 1, 2, 3, 4] + * indexes([0, 1], [4, 3], [8, 2]) => [0, 4, 5, 6, 8, 9] + * + * @param {Array} pairs + * [index, length] tuples as described above. + * @returns {Array} + * An array of indexes. + */ +function indexes(...pairs) { + return pairs.reduce((indexesArray, [start, len]) => { + for (let i = start; i < start + len; i++) { + indexesArray.push(i); + } + return indexesArray; + }, []); +} diff --git a/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js b/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js new file mode 100644 index 0000000000..ede2f9cc66 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_suggestedIndexRelativeToGroup.js @@ -0,0 +1,596 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests results with `suggestedIndex` and `isSuggestedIndexRelativeToGroup`. + +"use strict"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const MAX_RESULTS = 10; +const MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; + +// Default result groups used in the tests below. +const RESULT_GROUPS = { + children: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + flexChildren: true, + children: [ + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + ], + }, + ], +}; + +let sandbox; +add_task(function setuo() { + sandbox = lazy.sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); +}); + +add_task(async function test() { + // Set a specific maxRichResults for sanity's sake. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, MAX_RESULTS); + + // Create the default non-suggestedIndex results we'll use for tests that + // don't specify `otherResults`. + let basicResults = [ + ...makeHistoryResults(), + ...makeFormHistoryResults(), + ...makeRemoteSuggestionResults(), + ]; + + // Test cases follow. Each object in `tests` has the following properties: + // + // * {string} desc + // * {object} suggestedIndexResults + // Describes the suggestedIndex results the test provider should return. + // Properties: + // * {number} suggestedIndex + // * {UrlbarUtils.RESULT_GROUP} group + // This will force the result to have the given group. + // * {array} expected + // Describes the expected results the muxer should return, i.e., the search + // results. Each object in the array describes a slice of expected results. + // The size of the slice is defined by the `count` property. + // * {UrlbarUtils.RESULT_GROUP} group + // The expected group of the results in the slice. + // * {number} count + // The number of results in the slice. + // * {number} [offset] + // Can be used to offset the starting index of the slice in the results. + // * {array} [otherResults] + // An array of results besides the group-relative suggestedIndex results + // that the provider should return. If not specified `basicResults` is used. + // * {array} [resultGroups] + // The result groups to use. If not specified `RESULT_GROUPS` is used. + let tests = [ + { + desc: "First result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "Last result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "First result in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + ], + }, + + { + desc: "Last result in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "First and last results in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 4, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 4 remote suggestions because they + // dupe the earlier form history. + offset: 4, + count: 3, + }, + ], + }, + + { + desc: "First and last results in GENERAL_PARENT", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "First result in GENERAL_PARENT, first result in GENERAL", + suggestedIndexResults: [ + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + { + suggestedIndex: 0, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 3, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: 0, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + count: 2, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + // The muxer will remove the first 3 remote suggestions because they + // dupe the earlier form history. + offset: 3, + count: 3, + }, + ], + }, + + { + desc: "Results in sibling group, no other results in same group", + otherResults: makeFormHistoryResults(), + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.FORM_HISTORY, + count: 9, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "Results in sibling group, no other results in same group, has child group", + resultGroups: { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + otherResults: makeRemoteSuggestionResults(), + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + count: 9, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + + { + desc: "Complex group nesting with global suggestedIndex with resultSpan", + resultGroups: { + children: [ + { + maxResultCount: 1, + children: [{ group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST }], + }, + { + flexChildren: true, + children: [ + { + flex: 2, + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + }, + { + flex: 1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + children: [{ group: UrlbarUtils.RESULT_GROUP.GENERAL }], + }, + ], + }, + ], + }, + otherResults: [ + // heuristic + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + suggestion: "foo", + lowerCaseSuggestion: "foo", + } + ), + { + heuristic: true, + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + } + ), + // global suggestedIndex with resultSpan = 2 + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + } + ), + { + suggestedIndex: 1, + resultSpan: 2, + } + ), + // remote suggestions + ...makeRemoteSuggestionResults(), + ], + suggestedIndexResults: [ + { + suggestedIndex: -1, + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + }, + ], + expected: [ + { + group: UrlbarUtils.RESULT_GROUP.HEURISTIC_TEST, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX, + suggestedIndex: 1, + resultSpan: 2, + count: 1, + }, + { + group: UrlbarUtils.RESULT_GROUP.REMOTE_SUGGESTION, + count: 6, + }, + { + group: UrlbarUtils.RESULT_GROUP.GENERAL_PARENT, + suggestedIndex: -1, + }, + ], + }, + ]; + + let controller = UrlbarTestUtils.newMockController(); + + for (let { + desc, + suggestedIndexResults, + expected, + resultGroups, + otherResults, + } of tests) { + info(`Running test: ${desc}`); + + setResultGroups(resultGroups || RESULT_GROUPS); + + // Make the array of all results and do a search. + let results = (otherResults || basicResults).concat( + makeSuggestedIndexResults(suggestedIndexResults) + ); + let provider = registerBasicTestProvider(results); + let context = createContext(undefined, { providers: [provider.name] }); + await UrlbarProvidersManager.startQuery(context, controller); + + // Make the list of expected results. + let expectedResults = []; + for (let { group, offset, count, suggestedIndex } of expected) { + // Find the index in `results` of the expected result. + let index = results.findIndex( + r => + UrlbarUtils.getResultGroup(r) == group && + r.suggestedIndex === suggestedIndex + ); + Assert.notEqual( + index, + -1, + "Sanity check: Expected result is in `results`" + ); + if (offset) { + index += offset; + } + + // Extract the expected number of results from `results` and append them + // to the expected results array. + count = count || 1; + expectedResults.push(...results.slice(index, index + count)); + } + + Assert.deepEqual(context.results, expectedResults); + + UrlbarProvidersManager.unregisterProvider(provider); + } +}); + +function makeHistoryResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://example.com/" + i } + ) + ); + } + return results; +} + +function makeRemoteSuggestionResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: "test", + query: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeFormHistoryResults(count = MAX_RESULTS) { + let results = []; + for (let i = 0; i < count; i++) { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { + engine: "test", + suggestion: "test " + i, + lowerCaseSuggestion: "test " + i, + } + ) + ); + } + return results; +} + +function makeSuggestedIndexResults(objects) { + return objects.map(({ suggestedIndex, group }) => + Object.assign( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + url: "http://example.com/si " + suggestedIndex, + } + ), + { + group, + suggestedIndex, + isSuggestedIndexRelativeToGroup: true, + } + ) + ); +} + +function setResultGroups(resultGroups) { + sandbox.restore(); + if (resultGroups) { + sandbox.stub(UrlbarPrefs, "resultGroups").get(() => resultGroups); + } +} diff --git a/browser/components/urlbar/tests/unit/test_tab_matches.js b/browser/components/urlbar/tests/unit/test_tab_matches.js new file mode 100644 index 0000000000..f4061bf3db --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tab_matches.js @@ -0,0 +1,354 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +testEngine_setup(); + +add_task(async function test_tab_matches() { + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); + + let uri1 = Services.io.newURI("http://abc.com/"); + let uri2 = Services.io.newURI("http://xyz.net/"); + let uri3 = Services.io.newURI("about:mozilla"); + let uri4 = Services.io.newURI("data:text/html,test"); + let uri5 = Services.io.newURI("http://foobar.org"); + await PlacesTestUtils.addVisits([ + { + uri: uri5, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }, + { uri: uri2, title: "xyz.net - we're better than ABC" }, + { uri: uri1, title: "ABC rocks" }, + ]); + await addOpenPages(uri1, 1); + // Pages that cannot be registered in history. + await addOpenPages(uri3, 1); + await addOpenPages(uri4, 1); + + info("basic tab match"); + let context = createContext("abc.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://abc.com/", + title: "ABC rocks", + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("three results, one tab match"); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("three results, both normal results are tab matches"); + await addOpenPages(uri2, 1); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("a container tab is not visible in 'switch to tab'"); + await addOpenPages(uri5, 1, /* userContextId: */ 3); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info( + "a container tab should not see 'switch to tab' for other container tabs" + ); + context = createContext("abc", { isPrivate: false, userContextId: 3 }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeTabSwitchResult(context, { + uri: "http://foobar.org/", + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("a different container tab should not see any 'switch to tab'"); + context = createContext("abc", { isPrivate: false, userContextId: 2 }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { uri: uri1.spec, title: "ABC rocks" }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info( + "three results, both normal results are tab matches, one has multiple tabs" + ); + await addOpenPages(uri2, 5); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + makeTabSwitchResult(context, { + uri: "http://xyz.net/", + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("three results, no tab matches"); + await removeOpenPages(uri1, 1); + await removeOpenPages(uri2, 6); + context = createContext("abc", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: uri1.spec, + title: "ABC rocks", + }), + makeVisitResult(context, { + uri: uri2.spec, + title: "xyz.net - we're better than ABC", + }), + makeVisitResult(context, { + uri: uri5.spec, + title: "foobar.org - much better than ABC, definitely better than XYZ", + }), + ], + }); + + info("tab match search with restriction character"); + await addOpenPages(uri1, 1); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE + " abc", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "abc", + alias: UrlbarTokenizer.RESTRICT.OPENPAGE, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("tab match with not-addable pages"); + context = createContext("mozilla", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages, no boundary search"); + context = createContext("ut:mo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages and restriction character"); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE + " mozilla", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + query: "mozilla", + alias: UrlbarTokenizer.RESTRICT.OPENPAGE, + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + ], + }); + + info("tab match with not-addable pages and only restriction character"); + context = createContext(UrlbarTokenizer.RESTRICT.OPENPAGE, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "data:text/html,test", + title: "data:text/html,test", + iconUri: UrlbarUtils.ICON.DEFAULT, + }), + makeTabSwitchResult(context, { + uri: "about:mozilla", + title: "about:mozilla", + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + + info("tab match should not return tags as part of the title"); + // Bookmark one of the pages, and add tags to it, to check they don't appear + // in the title. + let bm = await PlacesUtils.bookmarks.insert({ + url: uri1, + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + }); + PlacesUtils.tagging.tagURI(uri1, ["test-tag"]); + context = createContext("abc.com", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "http://abc.com/", + title: "ABC rocks", + heuristic: true, + }), + makeTabSwitchResult(context, { + uri: "http://abc.com/", + title: "ABC rocks", + }), + ], + }); + await PlacesUtils.bookmarks.remove(bm); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js b/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js new file mode 100644 index 0000000000..f7994326ee --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_caseInsensitivity.js @@ -0,0 +1,137 @@ +/* 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/. */ + +testEngine_setup(); + +/** + * Checks the results of a search for `searchTerm`. + * + * @param {Array} uris + * A 2-element array containing [{string} uri, {array} tags}], where `tags` + * is a comma-separated list of the tags expected to appear in the search. + * @param {string} searchTerm + * The term to search for + */ +async function ensure_tag_results(uris, searchTerm) { + print("Searching for '" + searchTerm + "'"); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let [uri, tags] of uris) { + urlbarResults.push( + makeBookmarkResult(context, { + uri, + title: "A title", + tags, + }) + ); + } + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +} + +const uri1 = "http://site.tld/1"; +const uri2 = "http://site.tld/2"; +const uri3 = "http://site.tld/3"; +const uri4 = "http://site.tld/4"; +const uri5 = "http://site.tld/5"; +const uri6 = "http://site.tld/6"; + +/** + * Properly tags a uri adding it to bookmarks. + * + * @param {string} url + * The URI to tag. + * @param {Array} tags + * The tags to add. + */ +async function tagURI(url, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "A title", + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), tags); +} + +/** + * Test bug #408221 + */ +add_task(async function test_tags_search_case_insensitivity() { + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + await tagURI(uri6, ["muD"]); + await tagURI(uri6, ["baR"]); + await tagURI(uri5, ["mud"]); + await tagURI(uri5, ["bar"]); + await tagURI(uri4, ["MUD"]); + await tagURI(uri4, ["BAR"]); + await tagURI(uri3, ["foO"]); + await tagURI(uri2, ["FOO"]); + await tagURI(uri1, ["Foo"]); + + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "foo" + ); + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "Foo" + ); + await ensure_tag_results( + [ + [uri1, ["Foo"]], + [uri2, ["Foo"]], + [uri3, ["Foo"]], + ], + "foO" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "bar mud" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "BAR MUD" + ); + await ensure_tag_results( + [ + [uri4, ["BAR", "MUD"]], + [uri5, ["BAR", "MUD"]], + [uri6, ["BAR", "MUD"]], + ], + "Bar Mud" + ); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js b/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js new file mode 100644 index 0000000000..596b439be5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_extendedUnicode.js @@ -0,0 +1,66 @@ +/* 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 autocomplete for non-English URLs that match the tag bug 416214. Also + * test bug 417441 by making sure escaped ascii characters like "+" remain + * escaped. + * + * - add a visit for a page with a non-English URL + * - add a tag for the page + * - search for the tag + * - test number of matches (should be exactly one) + * - make sure the url is decoded + */ + +testEngine_setup(); + +add_task(async function test_tag_match_url() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + info( + "Make sure tag matches return the right url as well as '+' remain escaped" + ); + let uri1 = Services.io.newURI("http://escaped/ユニコード"); + let uri2 = Services.io.newURI("http://asciiescaped/blocking-firefox3%2B"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "title" }, + { uri: uri2, title: "title" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "title", + tags: ["superTag"], + style: ["bookmark-tag"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "title", + tags: ["superTag"], + style: ["bookmark-tag"], + }); + let context = createContext("superTag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "title", + tags: ["superTag"], + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "title", + tags: ["superTag"], + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_general.js b/browser/components/urlbar/tests/unit/test_tags_general.js new file mode 100644 index 0000000000..c2c620c152 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_general.js @@ -0,0 +1,207 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +testEngine_setup(); + +/** + * Checks the results of a search for `searchTerm`. + * + * @param {Array} uris + * A 2-element array containing [{string} uri, {array} tags}], where `tags` + * is a comma-separated list of the tags expected to appear in the search. + * @param {string} searchTerm + * The term to search for + */ +async function ensure_tag_results(uris, searchTerm) { + print("Searching for '" + searchTerm + "'"); + let context = createContext(searchTerm, { isPrivate: false }); + let urlbarResults = []; + for (let [uri, tags] of uris) { + urlbarResults.push( + makeBookmarkResult(context, { + uri, + title: "A title", + tags, + }) + ); + } + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + ...urlbarResults, + ], + }); +} + +var uri1 = "http://site.tld/1/aaa"; +var uri2 = "http://site.tld/2/bbb"; +var uri3 = "http://site.tld/3/aaa"; +var uri4 = "http://site.tld/4/bbb"; +var uri5 = "http://site.tld/5/aaa"; +var uri6 = "http://site.tld/6/bbb"; + +var tests = [ + () => + ensure_tag_results( + [ + [uri1, ["foo"]], + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "foo" + ), + () => ensure_tag_results([[uri1, ["foo"]]], "foo aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "foo bbb" + ), + () => + ensure_tag_results( + [ + [uri2, ["bar"]], + [uri4, ["foo bar"]], + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "bar" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "bar aaa"), + () => + ensure_tag_results( + [ + [uri2, ["bar"]], + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "bar bbb" + ), + () => + ensure_tag_results( + [ + [uri3, ["cheese"]], + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "cheese" + ), + () => + ensure_tag_results( + [ + [uri3, ["cheese"]], + [uri5, ["bar cheese"]], + ], + "chees aaa" + ), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "chees bbb"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "fo bar" + ), + () => ensure_tag_results([], "fo bar aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "fo bar bbb" + ), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "ba foo" + ), + () => ensure_tag_results([], "ba foo aaa"), + () => + ensure_tag_results( + [ + [uri4, ["foo bar"]], + [uri6, ["foo bar cheese"]], + ], + "ba foo bbb" + ), + () => + ensure_tag_results( + [ + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "ba chee" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "ba chee aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "ba chee bbb"), + () => + ensure_tag_results( + [ + [uri5, ["bar cheese"]], + [uri6, ["foo bar cheese"]], + ], + "cheese bar" + ), + () => ensure_tag_results([[uri5, ["bar cheese"]]], "cheese bar aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "chees bar bbb"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "cheese bar foo"), + () => ensure_tag_results([], "foo bar cheese aaa"), + () => ensure_tag_results([[uri6, ["foo bar cheese"]]], "foo bar cheese bbb"), +]; + +/** + * Properly tags a uri adding it to bookmarks. + * + * @param {string} url + * The URI to tag. + * @param {Array} tags + * The tags to add. + */ +async function tagURI(url, tags) { + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url, + title: "A title", + }); + PlacesUtils.tagging.tagURI(Services.io.newURI(url), tags); +} + +/** + * Test history autocomplete + */ +add_task(async function test_history_autocomplete_tags() { + // always search in history + bookmarks, no matter what the default is + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmarks", true); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmarks"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + await tagURI(uri6, ["foo bar cheese"]); + await tagURI(uri5, ["bar cheese"]); + await tagURI(uri4, ["foo bar"]); + await tagURI(uri3, ["cheese"]); + await tagURI(uri2, ["bar"]); + await tagURI(uri1, ["foo"]); + + for (let tagTest of tests) { + await tagTest(); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js b/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js new file mode 100644 index 0000000000..98d12ebe32 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_matchBookmarkTitles.js @@ -0,0 +1,42 @@ +/* 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 bug 416211 to make sure results that match the tag show the bookmark + * title instead of the page title. + */ + +testEngine_setup(); + +add_task(async function test_tag_match_has_bookmark_title() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + + info("Make sure the tag match gives the bookmark title"); + let uri = Services.io.newURI("http://theuri/"); + await PlacesTestUtils.addVisits({ uri, title: "Page title" }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri, + title: "Bookmark title", + tags: ["superTag"], + }); + let context = createContext("superTag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri.spec, + title: "Bookmark title", + tags: ["superTag"], + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js b/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js new file mode 100644 index 0000000000..d5f18278fd --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tags_returnedInSearches.js @@ -0,0 +1,125 @@ +/* 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 bug 418257 by making sure tags are returned with the title as part of + * the "comment" if there are tags even if we didn't match in the tags. They + * are separated from the title by a endash. + */ + +testEngine_setup(); + +add_task(async function test() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + }); + + let uri1 = Services.io.newURI("http://page1"); + let uri2 = Services.io.newURI("http://page2"); + let uri3 = Services.io.newURI("http://page3"); + let uri4 = Services.io.newURI("http://page4"); + await PlacesTestUtils.addVisits([ + { uri: uri1, title: "tagged" }, + { uri: uri2, title: "tagged" }, + { uri: uri3, title: "tagged" }, + { uri: uri4, title: "tagged" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri1, + title: "tagged", + tags: ["tag1"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri2, + title: "tagged", + tags: ["tag1", "tag2"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri3, + title: "tagged", + tags: ["tag1", "tag3"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: uri4, + title: "tagged", + tags: ["tag1", "tag2", "tag3"], + }); + info("Make sure tags come back in the title when matching tags"); + let context = createContext("page1 tag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri1.spec, + title: "tagged", + tags: ["tag1"], + }), + ], + }); + + info("Check tags in title for page2"); + context = createContext("page2 tag", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "tagged", + tags: ["tag1", "tag2"], + }), + ], + }); + + info("Tags do not appear when not matching the tag"); + context = createContext("page3", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri3.spec, + title: "tagged", + tags: [], + }), + ], + }); + + info("Extra test just to make sure we match the title"); + context = createContext("tag2", { isPrivate: true }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: uri4.spec, + title: "tagged", + tags: ["tag2"], + }), + makeBookmarkResult(context, { + uri: uri2.spec, + title: "tagged", + tags: ["tag2"], + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_tokenizer.js b/browser/components/urlbar/tests/unit/test_tokenizer.js new file mode 100644 index 0000000000..835d1a5909 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tokenizer.js @@ -0,0 +1,449 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test_tokenizer() { + let testContexts = [ + { desc: "Empty string", searchString: "", expectedTokens: [] }, + { desc: "Spaces string", searchString: " ", expectedTokens: [] }, + { + desc: "Single word string", + searchString: "test", + expectedTokens: [{ value: "test", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Multi word string with mixed whitespace types", + searchString: " test1 test2\u1680test3\u2004test4\u1680", + expectedTokens: [ + { value: "test1", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test2", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test3", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "test4", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "separate restriction char at beginning", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "separate restriction char at end", + searchString: `test ${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + ], + }, + { + desc: "boundary restriction char at end", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "boundary search restriction char at end", + searchString: `test${UrlbarTokenizer.RESTRICT.SEARCH}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "separate restriction char in the middle", + searchString: `test ${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "restriction char in the middle", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}test`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}test`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "restriction char in the middle 2", + searchString: `test${UrlbarTokenizer.RESTRICT.BOOKMARK} test`, + expectedTokens: [ + { + value: `test${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: `test`, type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "double boundary restriction char", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK}test${UrlbarTokenizer.RESTRICT.TITLE}`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { + value: `test${UrlbarTokenizer.RESTRICT.TITLE}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + ], + }, + { + desc: "double non-combinable restriction char, single char string", + searchString: `t${UrlbarTokenizer.RESTRICT.BOOKMARK}${UrlbarTokenizer.RESTRICT.SEARCH}`, + expectedTokens: [ + { + value: `t${UrlbarTokenizer.RESTRICT.BOOKMARK}`, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "only boundary restriction chars", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK}${UrlbarTokenizer.RESTRICT.TITLE}`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { + value: UrlbarTokenizer.RESTRICT.TITLE, + type: UrlbarTokenizer.TYPE.RESTRICT_TITLE, + }, + ], + }, + { + desc: "only the boundary restriction char", + searchString: UrlbarTokenizer.RESTRICT.BOOKMARK, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + ], + }, + // Some restriction chars may be # or ?, that are also valid path parts. + // The next 2 tests will check we consider those as part of url paths. + { + desc: "boundary # char on path", + searchString: "test/#", + expectedTokens: [ + { value: "test/#", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "boundary ? char on path", + searchString: "test/?", + expectedTokens: [ + { value: "test/?", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "multiple boundary restriction chars suffix", + searchString: `test ${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.TAG}`, + expectedTokens: [ + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + { + value: UrlbarTokenizer.RESTRICT.HISTORY, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.TAG, + type: UrlbarTokenizer.TYPE.RESTRICT_TAG, + }, + ], + }, + { + desc: "multiple boundary restriction chars prefix", + searchString: `${UrlbarTokenizer.RESTRICT.HISTORY} ${UrlbarTokenizer.RESTRICT.TAG} test`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.HISTORY, + type: UrlbarTokenizer.TYPE.RESTRICT_HISTORY, + }, + { + value: UrlbarTokenizer.RESTRICT.TAG, + type: UrlbarTokenizer.TYPE.TEXT, + }, + { value: "test", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Math with division", + searchString: "3.6/1.2", + expectedTokens: [{ value: "3.6/1.2", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "ipv4 in bookmarks", + searchString: `${UrlbarTokenizer.RESTRICT.BOOKMARK} 192.168.1.1:8`, + expectedTokens: [ + { + value: UrlbarTokenizer.RESTRICT.BOOKMARK, + type: UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + }, + { value: "192.168.1.1:8", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "email", + searchString: "test@mozilla.com", + expectedTokens: [ + { value: "test@mozilla.com", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "email2", + searchString: "test.test@mozilla.co.uk", + expectedTokens: [ + { value: "test.test@mozilla.co.uk", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "protocol", + searchString: "http://test", + expectedTokens: [ + { value: "http://test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "bogus protocol with host (we allow visits to http://///example.com)", + searchString: "http:///test", + expectedTokens: [ + { value: "http:///test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "file protocol with path", + searchString: "file:///home", + expectedTokens: [ + { value: "file:///home", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "almost a protocol", + searchString: "http:", + expectedTokens: [ + { value: "http:", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "almost a protocol 2", + searchString: "http:/", + expectedTokens: [ + { value: "http:/", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "bogus protocol (we allow visits to http://///example.com)", + searchString: "http:///", + expectedTokens: [ + { value: "http:///", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "file protocol", + searchString: "file:///", + expectedTokens: [ + { value: "file:///", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "userinfo", + searchString: "user:pass@test", + expectedTokens: [ + { value: "user:pass@test", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "domain", + searchString: "www.mozilla.org", + expectedTokens: [ + { + value: "www.mozilla.org", + type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN, + }, + ], + }, + { + desc: "data uri", + searchString: "data:text/plain,Content", + expectedTokens: [ + { + value: "data:text/plain,Content", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + ], + }, + { + desc: "ipv6", + searchString: "[2001:db8::1]", + expectedTokens: [ + { value: "[2001:db8::1]", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "numeric domain", + searchString: "test1001.com", + expectedTokens: [ + { value: "test1001.com", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "invalid ip", + searchString: "192.2134.1.2", + expectedTokens: [ + { value: "192.2134.1.2", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "ipv4", + searchString: "1.2.3.4", + expectedTokens: [ + { value: "1.2.3.4", type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN }, + ], + }, + { + desc: "host/path", + searchString: "test/test", + expectedTokens: [ + { value: "test/test", type: UrlbarTokenizer.TYPE.POSSIBLE_URL }, + ], + }, + { + desc: "percent encoded string", + searchString: "%E6%97%A5%E6%9C%AC", + expectedTokens: [ + { value: "%E6%97%A5%E6%9C%AC", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Uppercase", + searchString: "TEST", + expectedTokens: [{ value: "TEST", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Mixed case 1", + searchString: "TeSt", + expectedTokens: [{ value: "TeSt", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Mixed case 2", + searchString: "tEsT", + expectedTokens: [{ value: "tEsT", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "Uppercase with spaces", + searchString: "TEST EXAMPLE", + expectedTokens: [ + { value: "TEST", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "EXAMPLE", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "Mixed case with spaces", + searchString: "TeSt eXaMpLe", + expectedTokens: [ + { value: "TeSt", type: UrlbarTokenizer.TYPE.TEXT }, + { value: "eXaMpLe", type: UrlbarTokenizer.TYPE.TEXT }, + ], + }, + { + desc: "plain number", + searchString: "1001", + expectedTokens: [{ value: "1001", type: UrlbarTokenizer.TYPE.TEXT }], + }, + { + desc: "data uri with spaces", + searchString: "data:text/html,oh hi?", + expectedTokens: [ + { + value: "data:text/html,oh hi?", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + ], + }, + { + desc: "data uri with spaces ignored with other tokens", + searchString: "hi data:text/html,oh hi?", + expectedTokens: [ + { + value: "hi", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: "data:text/html,oh", + type: UrlbarTokenizer.TYPE.POSSIBLE_URL, + }, + { + value: "hi", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: UrlbarTokenizer.RESTRICT.SEARCH, + type: UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + }, + ], + }, + { + desc: "whitelisted host", + searchString: "test whitelisted", + expectedTokens: [ + { + value: "test", + type: UrlbarTokenizer.TYPE.TEXT, + }, + { + value: "whitelisted", + type: UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN, + }, + ], + }, + ]; + + Services.prefs.setBoolPref("browser.fixup.domainwhitelist.whitelisted", true); + + for (let queryContext of testContexts) { + info(queryContext.desc); + queryContext.trimmedSearchString = queryContext.searchString.trim(); + for (let token of queryContext.expectedTokens) { + token.lowerCaseValue = token.value.toLocaleLowerCase(); + } + let newQueryContext = UrlbarTokenizer.tokenize(queryContext); + Assert.equal( + queryContext, + newQueryContext, + "The queryContext object is the same" + ); + Assert.deepEqual( + queryContext.tokens, + queryContext.expectedTokens, + "Check the expected tokens" + ); + } +}); diff --git a/browser/components/urlbar/tests/unit/test_trimming.js b/browser/components/urlbar/tests/unit/test_trimming.js new file mode 100644 index 0000000000..5f0b340b53 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_trimming.js @@ -0,0 +1,171 @@ +/* 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/. */ + +add_task(async function setup() { + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); +}); + +add_task(async function test_untrimmed_secure_www() { + info("Searching for untrimmed https://www entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "https://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "https://www.mozilla.org/", + fallbackTitle: "https://www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.mozilla.org/test/", + title: "test visit for https://www.mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure_www_path() { + info("Searching for untrimmed https://www entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "https://www.mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "https://www.mozilla.org/test/", + title: "test visit for https://www.mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure() { + info("Searching for untrimmed https:// entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "https://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "https://mozilla.org/", + fallbackTitle: "https://mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://mozilla.org/test/", + title: "test visit for https://mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_secure_path() { + info("Searching for untrimmed https:// entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "https://mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "https://mozilla.org/test/", + title: "test visit for https://mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www() { + info("Searching for untrimmed http://www entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://www.mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/", + fallbackTitle: "www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.mozilla.org/test/", + title: "test visit for http://www.mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_www_path() { + info("Searching for untrimmed http://www entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("http://www.mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "http://www.mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "http://www.mozilla.org/test/", + title: "test visit for http://www.mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_escaped_chars() { + info("Searching for URL with characters that are normally escaped"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("https://www.mozilla.org/啊-test"), + }); + let context = createContext("https://www.mozilla.org/啊-test", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + uri: "https://www.mozilla.org/%E5%95%8A-test", + title: "test visit for https://www.mozilla.org/%E5%95%8A-test", + iconUri: "page-icon:https://www.mozilla.org/%E5%95%8A-test", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_unitConversion.js b/browser/components/urlbar/tests/unit/test_unitConversion.js new file mode 100644 index 0000000000..fc0620e7aa --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_unitConversion.js @@ -0,0 +1,504 @@ +/* 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/. */ + +"use strict"; + +/** + * Unit test for unit conversion module. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderUnitConversion: + "resource:///modules/UrlbarProviderUnitConversion.sys.mjs", +}); + +const TEST_DATA = [ + { + category: "angle", + cases: [ + { queryString: "1 d to d", expected: "1 deg" }, + { queryString: "-1 d to d", expected: "-1 deg" }, + { queryString: "1 d in d", expected: "1 deg" }, + { queryString: "1 d = d", expected: "1 deg" }, + { queryString: "1 D=D", expected: "1 deg" }, + { queryString: "1 d to degree", expected: "1 deg" }, + { queryString: "2 d to degree", expected: "2 deg" }, + { + queryString: "1 d to radian", + expected: `${round(Math.PI / 180)} radian`, + }, + { + queryString: "2 d to radian", + expected: `${round((Math.PI / 180) * 2)} radian`, + }, + { queryString: "1 d to rad", expected: `${round(Math.PI / 180)} radian` }, + { queryString: "1 d to r", expected: `${round(Math.PI / 180)} radian` }, + { queryString: "1 d to gradian", expected: `${round(1 / 0.9)} gradian` }, + { queryString: "1 d to g", expected: `${round(1 / 0.9)} gradian` }, + { queryString: "1 d to minute", expected: "60 min" }, + { queryString: "1 d to min", expected: "60 min" }, + { queryString: "1 d to m", expected: "60 min" }, + { queryString: "1 d to second", expected: "3,600 sec" }, + { queryString: "1 d to sec", expected: "3,600 sec" }, + { queryString: "1 d to s", expected: "3,600 sec" }, + { queryString: "1 d to sign", expected: `${round(1 / 30)} sign` }, + { queryString: "1 d to mil", expected: `${round(1 / 0.05625)} mil` }, + { + queryString: "1 d to revolution", + expected: `${round(1 / 360)} revolution`, + }, + { queryString: "1 d to circle", expected: `${round(1 / 360)} circle` }, + { queryString: "1 d to turn", expected: `${round(1 / 360)} turn` }, + { queryString: "1 d to quadrant", expected: `${round(1 / 90)} quadrant` }, + { + queryString: "1 d to rightangle", + expected: `${round(1 / 90)} rightangle`, + }, + { queryString: "1 d to sextant", expected: `${round(1 / 60)} sextant` }, + { queryString: "1 degree to d", expected: "1 deg" }, + { queryString: "1 radian to d", expected: `${round(180 / Math.PI)} deg` }, + { + queryString: "1 r to g", + expected: `${round(180 / Math.PI / 0.9)} gradian`, + }, + ], + }, + { + category: "force", + cases: [ + { queryString: "1 n to n", expected: "1 newton" }, + { queryString: "-1 n to n", expected: "-1 newton" }, + { queryString: "1 n in n", expected: "1 newton" }, + { queryString: "1 n = n", expected: "1 newton" }, + { queryString: "1 N=N", expected: "1 newton" }, + { queryString: "1 n to newton", expected: "1 newton" }, + { queryString: "1 n to kilonewton", expected: "0.001 kilonewton" }, + { queryString: "1 n to kn", expected: "0.001 kilonewton" }, + { + queryString: "1 n to gram-force", + expected: `${round(101.9716213)} gram-force`, + }, + { + queryString: "1 n to gf", + expected: `${round(101.9716213)} gram-force`, + }, + { + queryString: "1 n to kilogram-force", + expected: `${round(0.1019716213)} kilogram-force`, + }, + { + queryString: "1 n to kgf", + expected: `${round(0.1019716213)} kilogram-force`, + }, + { + queryString: "1 n to ton-force", + expected: `${round(0.0001019716213)} ton-force`, + }, + { + queryString: "1 n to tf", + expected: `${round(0.0001019716213)} ton-force`, + }, + { + queryString: "1 n to exanewton", + expected: `${round(1.0e-18)} exanewton`, + }, + { queryString: "1 n to en", expected: `${round(1.0e-18)} exanewton` }, + { + queryString: "1 n to petanewton", + expected: `${round(1.0e-15)} petanewton`, + }, + { queryString: "1 n to PN", expected: `${round(1.0e-15)} petanewton` }, + { queryString: "1 n to Pn", expected: `${round(1.0e-15)} petanewton` }, + { + queryString: "1 n to teranewton", + expected: `${round(1.0e-12)} teranewton`, + }, + { queryString: "1 n to tn", expected: `${round(1.0e-12)} teranewton` }, + { + queryString: "1 n to giganewton", + expected: `${round(1.0e-9)} giganewton`, + }, + { queryString: "1 n to gn", expected: `${round(1.0e-9)} giganewton` }, + { queryString: "1 n to meganewton", expected: "0.000001 meganewton" }, + { queryString: "1 n to MN", expected: "0.000001 meganewton" }, + { queryString: "1 n to Mn", expected: "0.000001 meganewton" }, + { queryString: "1 n to hectonewton", expected: "0.01 hectonewton" }, + { queryString: "1 n to hn", expected: "0.01 hectonewton" }, + { queryString: "1 n to dekanewton", expected: "0.1 dekanewton" }, + { queryString: "1 n to dan", expected: "0.1 dekanewton" }, + { queryString: "1 n to decinewton", expected: "10 decinewton" }, + { queryString: "1 n to dn", expected: "10 decinewton" }, + { queryString: "1 n to centinewton", expected: "100 centinewton" }, + { queryString: "1 n to cn", expected: "100 centinewton" }, + { queryString: "1 n to millinewton", expected: "1000 millinewton" }, + { queryString: "1 n to mn", expected: "1000 millinewton" }, + { queryString: "1 n to micronewton", expected: "1000000 micronewton" }, + { queryString: "1 n to µn", expected: "1000000 micronewton" }, + { + queryString: "1 n to nanonewton", + expected: "1000000000 nanonewton", + }, + { queryString: "1 n to nn", expected: "1000000000 nanonewton" }, + { + queryString: "1 n to piconewton", + expected: "1000000000000 piconewton", + }, + { queryString: "1 n to pn", expected: "1000000000000 piconewton" }, + { + queryString: "1 n to femtonewton", + expected: "1000000000000000 femtonewton", + }, + { queryString: "1 n to fn", expected: "1000000000000000 femtonewton" }, + { + queryString: "1 n to attonewton", + expected: "1000000000000000000 attonewton", + }, + { queryString: "1 n to an", expected: "1000000000000000000 attonewton" }, + { queryString: "1 n to dyne", expected: "100000 dyne" }, + { queryString: "1 n to dyn", expected: "100000 dyne" }, + { queryString: "1 n to joule/meter", expected: "1 joule/meter" }, + { queryString: "1 n to j/m", expected: "1 joule/meter" }, + { + queryString: "1 n to joule/centimeter", + expected: "100 joule/centimeter", + }, + { queryString: "1 n to j/cm", expected: "100 joule/centimeter" }, + { + queryString: "1 n to ton-force-short", + expected: `${round(0.0001124045)} ton-force-short`, + }, + { + queryString: "1 n to short", + expected: `${round(0.0001124045)} ton-force-short`, + }, + { + queryString: "1 n to ton-force-long", + expected: `${round(0.0001003611)} ton-force-long`, + }, + { + queryString: "1 n to tonf", + expected: `${round(0.0001003611)} ton-force-long`, + }, + { + queryString: "1 n to kip-force", + expected: `${round(0.0002248089)} kip-force`, + }, + { + queryString: "1 n to kipf", + expected: `${round(0.0002248089)} kip-force`, + }, + { + queryString: "1 n to pound-force", + expected: `${round(0.2248089431)} pound-force`, + }, + { + queryString: "1 n to lbf", + expected: `${round(0.2248089431)} pound-force`, + }, + { + queryString: "1 n to ounce-force", + expected: `${round(3.5969430896)} ounce-force`, + }, + { + queryString: "1 n to ozf", + expected: `${round(3.5969430896)} ounce-force`, + }, + { + queryString: "1 n to poundal", + expected: `${round(7.2330138512)} poundal`, + }, + { queryString: "1 n to pdl", expected: `${round(7.2330138512)} poundal` }, + { queryString: "1 n to pond", expected: `${round(101.9716213)} pond` }, + { queryString: "1 n to p", expected: `${round(101.9716213)} pond` }, + { + queryString: "1 n to kilopond", + expected: `${round(0.1019716213)} kilopond`, + }, + { queryString: "1 n to kp", expected: `${round(0.1019716213)} kilopond` }, + { queryString: "1 kilonewton to n", expected: "1000 newton" }, + ], + }, + { + category: "length", + cases: [ + { queryString: "1 meter to meter", expected: "1 m" }, + { queryString: "-1 meter to meter", expected: "-1 m" }, + { queryString: "1 meter in meter", expected: "1 m" }, + { queryString: "1 meter = meter", expected: "1 m" }, + { queryString: "1 METER=METER", expected: "1 m" }, + { queryString: "1 m to meter", expected: "1 m" }, + { queryString: "1 m to nanometer", expected: "1000000000 nanometer" }, + { queryString: "1 m to micrometer", expected: "1000000 micrometer" }, + { queryString: "1 m to millimeter", expected: "1,000 mm" }, + { queryString: "1 m to mm", expected: "1,000 mm" }, + { queryString: "1 m to centimeter", expected: "100 cm" }, + { queryString: "1 m to cm", expected: "100 cm" }, + { queryString: "1 m to kilometer", expected: "0.001 km" }, + { queryString: "1 m to km", expected: "0.001 km" }, + { queryString: "1 m to mile", expected: `${round(0.0006213689)} mi` }, + { queryString: "1 m to mi", expected: `${round(0.0006213689)} mi` }, + { queryString: "1 m to yard", expected: `${round(1.0936132983)} yd` }, + { queryString: "1 m to yd", expected: `${round(1.0936132983)} yd` }, + { queryString: "1 m to foot", expected: `${round(3.280839895)} ft` }, + { queryString: "1 m to ft", expected: `${round(3.280839895)} ft` }, + { queryString: "1 m to inch", expected: `${round(39.37007874)} in` }, + { queryString: "1 inch to m", expected: `${round(1 / 39.37007874)} m` }, + ], + }, + { + category: "mass", + cases: [ + { queryString: "1 kg to kg", expected: "1 kg" }, + { queryString: "-1 kg to kg", expected: "-1 kg" }, + { queryString: "1 kg in kg", expected: "1 kg" }, + { queryString: "1 kg = kg", expected: "1 kg" }, + { queryString: "1 KG=KG", expected: "1 kg" }, + { queryString: "1 kg to kilogram", expected: "1 kg" }, + { queryString: "1 kg to gram", expected: "1,000 g" }, + { queryString: "1 kg to g", expected: "1,000 g" }, + { queryString: "1 kg to milligram", expected: "1000000 milligram" }, + { queryString: "1 kg to mg", expected: "1000000 milligram" }, + { queryString: "1 kg to ton", expected: "0.001 ton" }, + { queryString: "1 kg to t", expected: "0.001 ton" }, + { + queryString: "1 kg to longton", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to l.t.", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to l/t", + expected: `${round(0.0009842073)} longton`, + }, + { + queryString: "1 kg to shortton", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to s.t.", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to s/t", + expected: `${round(0.0011023122)} shortton`, + }, + { + queryString: "1 kg to pound", + expected: `${round(2.2046244202)} lb`, + }, + { queryString: "1 kg to lbs", expected: `${round(2.2046244202)} lb` }, + { + queryString: "1 kg to lb", + expected: `${round(2.2046244202)} lb`, + }, + { + queryString: "1 kg to ounce", + expected: `${round(35.273990723)} oz`, + }, + { queryString: "1 kg to oz", expected: `${round(35.273990723)} oz` }, + { queryString: "1 kg to carat", expected: "5000 carat" }, + { queryString: "1 kg to ffd", expected: "5000 ffd" }, + { queryString: "1 ffd to kg", expected: `${round(1 / 5000)} kg` }, + ], + }, + { + category: "temperature", + cases: [ + { queryString: "0 c to c", expected: "0°C" }, + { queryString: "0 c in c", expected: "0°C" }, + { queryString: "0 c = c", expected: "0°C" }, + { queryString: "0 C=C", expected: "0°C" }, + { queryString: "0 c to celsius", expected: "0°C" }, + { queryString: "0 c to kelvin", expected: "273.15 kelvin" }, + { queryString: "0 c to k", expected: "273.15 kelvin" }, + { queryString: "10 c to k", expected: "283.15 kelvin" }, + { queryString: "0 c to fahrenheit", expected: "32°F" }, + { queryString: "0 c to f", expected: "32°F" }, + { queryString: "10 c to f", expected: `${round(10 * 1.8 + 32)}°F` }, + { + queryString: "10 f to kelvin", + expected: `${round((10 - 32) / 1.8 + 273.15)} kelvin`, + }, + { queryString: "-10 c to f", expected: "14°F" }, + ], + }, + { + category: "timezone", + cases: [ + { queryString: "0 utc to utc", expected: "00:00 UTC" }, + { queryString: "0 utc in utc", expected: "00:00 UTC" }, + { queryString: "0 utc = utc", expected: "00:00 UTC" }, + { queryString: "0 UTC=UTC", expected: "00:00 UTC" }, + { queryString: "11 pm utc to utc", expected: "11:00 PM UTC" }, + { queryString: "11 am utc to utc", expected: "11:00 AM UTC" }, + { queryString: "11:30 utc to utc", expected: "11:30 UTC" }, + { queryString: "11:30 PM utc to utc", expected: "11:30 PM UTC" }, + { queryString: "1 utc to idlw", expected: "13:00 IDLW" }, + { queryString: "1 pm utc to idlw", expected: "1:00 AM IDLW" }, + { queryString: "1 am utc to idlw", expected: "1:00 PM IDLW" }, + { queryString: "1 utc to idlw", expected: "13:00 IDLW" }, + { queryString: "1 PM utc to idlw", expected: "1:00 AM IDLW" }, + { queryString: "0 utc to nt", expected: "13:00 NT" }, + { queryString: "0 utc to hst", expected: "14:00 HST" }, + { queryString: "0 utc to akst", expected: "15:00 AKST" }, + { queryString: "0 utc to pst", expected: "16:00 PST" }, + { queryString: "0 utc to akdt", expected: "16:00 AKDT" }, + { queryString: "0 utc to mst", expected: "17:00 MST" }, + { queryString: "0 utc to pdt", expected: "17:00 PDT" }, + { queryString: "0 utc to cst", expected: "18:00 CST" }, + { queryString: "0 utc to mdt", expected: "18:00 MDT" }, + { queryString: "0 utc to est", expected: "19:00 EST" }, + { queryString: "0 utc to cdt", expected: "19:00 CDT" }, + { queryString: "0 utc to edt", expected: "20:00 EDT" }, + { queryString: "0 utc to ast", expected: "20:00 AST" }, + { queryString: "0 utc to guy", expected: "21:00 GUY" }, + { queryString: "0 utc to adt", expected: "21:00 ADT" }, + { queryString: "0 utc to at", expected: "22:00 AT" }, + { queryString: "0 utc to gmt", expected: "00:00 GMT" }, + { queryString: "0 utc to z", expected: "00:00 Z" }, + { queryString: "0 utc to wet", expected: "00:00 WET" }, + { queryString: "0 utc to west", expected: "01:00 WEST" }, + { queryString: "0 utc to cet", expected: "01:00 CET" }, + { queryString: "0 utc to bst", expected: "01:00 BST" }, + { queryString: "0 utc to ist", expected: "01:00 IST" }, + { queryString: "0 utc to cest", expected: "02:00 CEST" }, + { queryString: "0 utc to eet", expected: "02:00 EET" }, + { queryString: "0 utc to eest", expected: "03:00 EEST" }, + { queryString: "0 utc to msk", expected: "03:00 MSK" }, + { queryString: "0 utc to msd", expected: "04:00 MSD" }, + { queryString: "0 utc to zp4", expected: "04:00 ZP4" }, + { queryString: "0 utc to zp5", expected: "05:00 ZP5" }, + { queryString: "0 utc to zp6", expected: "06:00 ZP6" }, + { queryString: "0 utc to wast", expected: "07:00 WAST" }, + { queryString: "0 utc to awst", expected: "08:00 AWST" }, + { queryString: "0 utc to wst", expected: "08:00 WST" }, + { queryString: "0 utc to jst", expected: "09:00 JST" }, + { queryString: "0 utc to acst", expected: "09:30 ACST" }, + { queryString: "0 utc to aest", expected: "10:00 AEST" }, + { queryString: "0 utc to acdt", expected: "10:30 ACDT" }, + { queryString: "0 utc to aedt", expected: "11:00 AEDT" }, + { queryString: "0 utc to nzst", expected: "12:00 NZST" }, + { queryString: "0 utc to idle", expected: "12:00 IDLE" }, + { queryString: "0 utc to nzd", expected: "13:00 NZD" }, + { queryString: "9:00 jst to utc", expected: "00:00 UTC" }, + { queryString: "8:00 jst to utc", expected: "23:00 UTC" }, + { queryString: "8:00 am jst to utc", expected: "11:00 PM UTC" }, + { queryString: "9:00 jst to pdt", expected: "17:00 PDT" }, + { queryString: "12 pm pst to cet", expected: "9:00 PM CET" }, + { queryString: "12 am pst to cet", expected: "9:00 AM CET" }, + { queryString: "12:30 pm pst to cet", expected: "9:30 PM CET" }, + { queryString: "12:30 am pst to cet", expected: "9:30 AM CET" }, + { queryString: "23 pm pst to cet", expected: "8:00 AM CET" }, + { queryString: "23:30 pm pst to cet", expected: "8:30 AM CET" }, + { + queryString: "10:00 JST to here", + timezone: "UTC", + expected: "01:00 UTC-000", + }, + { + queryString: "1:00 to JST", + timezone: "UTC", + expected: "10:00 JST", + }, + { + queryString: "1 am to JST", + timezone: "UTC", + expected: "10:00 AM JST", + }, + { + queryString: "now to JST", + timezone: "UTC", + assertResult: output => { + const outputRegexResult = /([0-9]+):([0-9]+)/.exec(output); + const outputMinutes = + parseInt(outputRegexResult[1]) * 60 + + parseInt(outputRegexResult[2]); + const nowDate = new Date(); + // Apply JST time difference. + nowDate.setHours(nowDate.getHours() + 9); + let nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes(); + // When we cross the day between the unit converter calculation and the + // assertion here. + nowMinutes = + outputMinutes > nowMinutes ? nowMinutes + 1440 : nowMinutes; + Assert.lessOrEqual(nowMinutes - outputMinutes, 1); + }, + }, + { + queryString: "now to here", + timezone: "UTC", + assertResult: output => { + const outputRegexResult = /([0-9]+):([0-9]+)/.exec(output); + const outputMinutes = + parseInt(outputRegexResult[1]) * 60 + + parseInt(outputRegexResult[2]); + const nowDate = new Date(); + let nowMinutes = nowDate.getHours() * 60 + nowDate.getMinutes(); + nowMinutes = + outputMinutes > nowMinutes ? nowMinutes + 1440 : nowMinutes; + Assert.lessOrEqual(nowMinutes - outputMinutes, 1); + }, + }, + ], + }, + { + category: "invalid", + cases: [ + { queryString: "1 to cm" }, + { queryString: "1cm to newton" }, + { queryString: "1cm to foo" }, + { queryString: "0:00:00 utc to jst" }, + ], + }, +]; + +add_task(function () { + // Enable unit conversion. + Services.prefs.setBoolPref("browser.urlbar.unitConversion.enabled", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.unitConversion.enabled"); + }); + + for (const { category, cases } of TEST_DATA) { + for (const { queryString, timezone, expected, assertResult } of cases) { + info(`Test "${queryString}" in ${category}`); + + let originalTimezone; + if (timezone) { + originalTimezone = Cu.getJSTestingFunctions().getTimeZone(); + info(`Set timezone ${timezone}`); + Cu.getJSTestingFunctions().setTimeZone(timezone); + } + + const context = createContext(queryString); + const isActive = UrlbarProviderUnitConversion.isActive(context); + Assert.equal(isActive, !!expected || !!assertResult); + + if (isActive) { + UrlbarProviderUnitConversion.startQuery(context, (module, result) => { + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.DYNAMIC); + Assert.equal(result.source, UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL); + Assert.equal(result.suggestedIndex, 1); + Assert.equal(result.payload.input, queryString); + + if (expected) { + Assert.equal(result.payload.output, expected); + } else { + assertResult(result.payload.output); + } + }); + } + + if (originalTimezone) { + Cu.getJSTestingFunctions().setTimeZone(originalTimezone); + } + } + } +}); + +function round(number) { + return parseFloat(number.toPrecision(10)); +} diff --git a/browser/components/urlbar/tests/unit/test_word_boundary_search.js b/browser/components/urlbar/tests/unit/test_word_boundary_search.js new file mode 100644 index 0000000000..f015a29fb5 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_word_boundary_search.js @@ -0,0 +1,403 @@ +/* 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 to make sure matches against the url, title, tags are first made on word + * boundaries, instead of in the middle of words, and later are extended to the + * whole words. For this test it is critical to check sorting of the matches. + * + * Make sure we don't try matching one after a CamelCase because the upper-case + * isn't really a word boundary. (bug 429498) + */ + +testEngine_setup(); + +var katakana = ["\u30a8", "\u30c9"]; // E, Do +var ideograph = ["\u4efb", "\u5929", "\u5802"]; // Nin Ten Do + +add_task(async function test_escape() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", false); + Services.prefs.setBoolPref("browser.urlbar.autoFill", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", false); + Services.prefs.setBoolPref("browser.urlbar.suggest.quickactions", false); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.prefs.clearUserPref("browser.urlbar.suggest.quickactions"); + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + }); + + await PlacesTestUtils.addVisits([ + { uri: "http://matchme/", title: "title1" }, + { uri: "http://dontmatchme/", title: "title1" }, + { uri: "http://title/1", title: "matchme2" }, + { uri: "http://title/2", title: "dontmatchme3" }, + { uri: "http://tag/1", title: "title1" }, + { uri: "http://tag/2", title: "title1" }, + { uri: "http://crazytitle/", title: "!@#$%^&*()_+{}|:<>?word" }, + { uri: "http://katakana/", title: katakana.join("") }, + { uri: "http://ideograph/", title: ideograph.join("") }, + { uri: "http://camel/pleaseMatchMe/", title: "title1" }, + ]); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }); + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }); + await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies(); + + info("Match 'match' at the beginning or after / or on a CamelCase"); + let context = createContext("match", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match 'dont' at the beginning or after /"); + context = createContext("dont", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match 'match' at the beginning or after / or on a CamelCase"); + context = createContext("2", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + ], + }); + + info("Match 't' at the beginning or after /"); + context = createContext("t", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Match 'word' after many consecutive word boundaries"); + context = createContext("word", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Match a word boundary '/' for everything"); + context = createContext("/", { isPrivate: false }); + // UNIX platforms can search for a file:// URL by typing a forward slash. + let heuristicSlashResult = + AppConstants.platform == "win" + ? makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }) + : makeVisitResult(context, { + uri: "file:///", + fallbackTitle: "file:///", + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + heuristic: true, + }); + await check_results({ + context, + matches: [ + heuristicSlashResult, + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + ], + }); + + info("Match word boundaries '()_' that are among word boundaries"); + context = createContext("()_", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://crazytitle/", + title: "!@#$%^&*()_+{}|:<>?word", + }), + ], + }); + + info("Katakana characters form a string, so match the beginning"); + context = createContext(katakana[0], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://katakana/", + title: katakana.join(""), + }), + ], + }); + + /* + info("Middle of a katakana word shouldn't be matched"); + await check_autocomplete({ + search: katakana[1], + matches: [ ], + }); +*/ + + info("Ideographs are treated as words so 'nin' is one word"); + context = createContext(ideograph[0], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Ideographs are treated as words so 'ten' is another word"); + context = createContext(ideograph[1], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Ideographs are treated as words so 'do' is yet another word"); + context = createContext(ideograph[2], { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://ideograph/", + title: ideograph.join(""), + }), + ], + }); + + info("Match in the middle. Should just be sorted by frecency."); + context = createContext("ch", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + ], + }); + + // Also this test should just be sorted by frecency. + info( + "Don't match one character after a camel-case word boundary (bug 429498). Should just be sorted by frecency." + ); + context = createContext("atch", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: SUGGESTIONS_ENGINE_NAME, + heuristic: true, + }), + makeBookmarkResult(context, { + uri: "http://tag/2", + title: "title1", + tags: ["dontmatchme3"], + }), + makeBookmarkResult(context, { + uri: "http://tag/1", + title: "title1", + tags: ["matchme2"], + }), + makeVisitResult(context, { + uri: "http://camel/pleaseMatchMe/", + title: "title1", + }), + makeVisitResult(context, { + uri: "http://title/2", + title: "dontmatchme3", + }), + makeVisitResult(context, { uri: "http://title/1", title: "matchme2" }), + makeVisitResult(context, { uri: "http://dontmatchme/", title: "title1" }), + makeVisitResult(context, { uri: "http://matchme/", title: "title1" }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/xpcshell.ini b/browser/components/urlbar/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..18a608e5f3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/xpcshell.ini @@ -0,0 +1,99 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +head = head.js +firefox-appdir = browser +support-files = + data/engine.xml + +[test_000_frecency.js] +[test_about_urls.js] +[test_autofill_adaptiveHistory.js] +[test_autofill_bookmarked.js] +[test_autofill_do_not_trim.js] +[test_autofill_functional.js] +[test_autofill_origins.js] +[test_autofill_origins_alt_frecency.js] +prefs = places.frecency.origins.alternative.featureGate=true +[test_autofill_originsAndQueries.js] +[test_autofill_prefix_fallback.js] +[test_autofill_search_engines.js] +[test_autofill_search_engine_aliases.js] +[test_autofill_urls.js] +[test_avoid_middle_complete.js] +[test_avoid_stripping_to_empty_tokens.js] +[test_calculator.js] +[test_casing.js] +[test_dedupe_prefix.js] +[test_dedupe_switchTab.js] +[test_download_embed_bookmarks.js] +[test_empty_search.js] +[test_encoded_urls.js] +[test_escaping_badEscapedURI.js] +[test_escaping_escapeSelf.js] +[test_exposure.js] +[test_frecency.js] +[test_frecency_alternative_nimbus.js] +[test_heuristic_cancel.js] +[test_hideSponsoredHistory.js] +[test_keywords.js] +skip-if = os == 'linux' # bug 1474616 +[test_l10nCache.js] +[test_local_suggest_prefs.js] +[test_match_javascript.js] +[test_multi_word_search.js] +[test_muxer.js] +[test_protocol_ignore.js] +[test_protocol_swap.js] +[test_providerAliasEngines.js] +[test_providerHeuristicFallback.js] +[test_providerHistoryUrlHeuristic.js] +[test_providerKeywords.js] +[test_providerOmnibox.js] +[test_providerOpenTabs.js] +skip-if = + os == "mac" && debug # Bug 1781972 + os == "win" && debug # Bug 1781972 +[test_providerPlaces.js] +[test_providerPlaces_duplicate_entries.js] +[test_providerPlaces_nonEnglish.js] +[test_providerPreloaded.js] +[test_providersManager.js] +[test_providersManager_filtering.js] +[test_providersManager_maxResults.js] +[test_providerTabToSearch.js] +[test_providerTabToSearch_partialHost.js] +[test_query_url.js] +[test_queryScorer.js] +[test_quickactions.js] +[test_remote_tabs.js] +skip-if = !sync +[test_resultGroups.js] +[test_search_engine_host.js] +[test_search_engine_restyle.js] +[test_search_suggestions.js] +[test_search_suggestions_aliases.js] +[test_search_suggestions_tail.js] +[test_special_search.js] +[test_suggestedIndex.js] +[test_suggestedIndexRelativeToGroup.js] +[test_tab_matches.js] +[test_tags_caseInsensitivity.js] +[test_tags_extendedUnicode.js] +[test_tags_general.js] +[test_tags_matchBookmarkTitles.js] +[test_tags_returnedInSearches.js] +[test_tokenizer.js] +[test_trimming.js] +[test_unitConversion.js] +[test_UrlbarController_integration.js] +[test_UrlbarController_telemetry.js] +[test_UrlbarController_unit.js] +[test_UrlbarPrefs.js] +[test_UrlbarQueryContext.js] +[test_UrlbarQueryContext_restrictSource.js] +[test_UrlbarSearchUtils.js] +[test_UrlbarUtils_addToUrlbarHistory.js] +[test_UrlbarUtils_getShortcutOrURIAndPostData.js] +[test_UrlbarUtils_getTokenMatches.js] +[test_UrlbarUtils_unEscapeURIForUI.js] +[test_word_boundary_search.js] diff --git a/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs new file mode 100644 index 0000000000..47ba0cc856 --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterSimple.sys.mjs @@ -0,0 +1,243 @@ +/* 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/. */ + +// NOTE: This units table need to be localized upon supporting multi locales +// since it supports en-US only. +// e.g. Should support plugada or funty as well for pound. +const UNITS_GROUPS = [ + { + // Angle + degree: 1, + deg: "degree", + d: "degree", + radian: Math.PI / 180.0, + rad: "radian", + r: "radian", + gradian: 1 / 0.9, + grad: "gradian", + g: "gradian", + minute: 60, + min: "minute", + m: "minute", + second: 3600, + sec: "second", + s: "second", + sign: 1 / 30.0, + mil: 1 / 0.05625, + revolution: 1 / 360.0, + circle: 1 / 360.0, + turn: 1 / 360.0, + quadrant: 1 / 90.0, + rightangle: 1 / 90.0, + sextant: 1 / 60.0, + }, + { + // Force + newton: 1, + n: "newton", + kilonewton: 0.001, + kn: "kilonewton", + "gram-force": 101.9716213, + gf: "gram-force", + "kilogram-force": 0.1019716213, + kgf: "kilogram-force", + "ton-force": 0.0001019716213, + tf: "ton-force", + exanewton: 1.0e-18, + en: "exanewton", + petanewton: 1.0e-15, + PN: "petanewton", + Pn: "petanewton", + teranewton: 1.0e-12, + tn: "teranewton", + giganewton: 1.0e-9, + gn: "giganewton", + meganewton: 0.000001, + MN: "meganewton", + Mn: "meganewton", + hectonewton: 0.01, + hn: "hectonewton", + dekanewton: 0.1, + dan: "dekanewton", + decinewton: 10, + dn: "decinewton", + centinewton: 100, + cn: "centinewton", + millinewton: 1000, + mn: "millinewton", + micronewton: 1000000, + µn: "micronewton", + nanonewton: 1000000000, + nn: "nanonewton", + piconewton: 1000000000000, + pn: "piconewton", + femtonewton: 1000000000000000, + fn: "femtonewton", + attonewton: 1000000000000000000, + an: "attonewton", + dyne: 100000, + dyn: "dyne", + "joule/meter": 1, + "j/m": "joule/meter", + "joule/centimeter": 100, + "j/cm": "joule/centimeter", + "ton-force-short": 0.0001124045, + short: "ton-force-short", + "ton-force-long": 0.0001003611, + tonf: "ton-force-long", + "kip-force": 0.0002248089, + kipf: "kip-force", + "pound-force": 0.2248089431, + lbf: "pound-force", + "ounce-force": 3.5969430896, + ozf: "ounce-force", + poundal: 7.2330138512, + pdl: "poundal", + pond: 101.9716213, + p: "pond", + kilopond: 0.1019716213, + kp: "kilopond", + }, + { + // Length + meter: 1, + m: "meter", + nanometer: 1000000000, + micrometer: 1000000, + millimeter: 1000, + mm: "millimeter", + centimeter: 100, + cm: "centimeter", + kilometer: 0.001, + km: "kilometer", + mile: 0.0006213689, + mi: "mile", + yard: 1.0936132983, + yd: "yard", + foot: 3.280839895, + feet: "foot", + ft: "foot", + inch: 39.37007874, + inches: "inch", + in: "inch", + }, + { + // Mass + kilogram: 1, + kg: "kilogram", + gram: 1000, + g: "gram", + milligram: 1000000, + mg: "milligram", + ton: 0.001, + t: "ton", + longton: 0.0009842073, + "l.t.": "longton", + "l/t": "longton", + shortton: 0.0011023122, + "s.t.": "shortton", + "s/t": "shortton", + pound: 2.2046244202, + lbs: "pound", + lb: "pound", + ounce: 35.273990723, + oz: "ounce", + carat: 5000, + ffd: 5000, + }, +]; + +// There are some units that will be same in lower case in same unit group. +// e.g. Mn: meganewton and mn: millinewton on force group. +// Handle them as case-sensitive. +const CASE_SENSITIVE_UNITS = ["PN", "Pn", "MN", "Mn"]; + +const NUMBER_REGEX = "-?\\d+(?:\\.\\d+)?\\s*"; +const UNIT_REGEX = "[A-Za-zµ0-9_./-]+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${NUMBER_REGEX})(${UNIT_REGEX})(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${UNIT_REGEX})`, + "i" +); + +const DECIMAL_PRECISION = 10; + +/** + * This module converts simple unit such as angle and length. + */ +export class UnitConverterSimple { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const target = findUnitGroup(regexResult[2], regexResult[3]); + + if (!target) { + return null; + } + + const { group, inputUnit, outputUnit } = target; + const inputNumber = Number(regexResult[1]); + const outputNumber = parseFloat( + ((inputNumber / group[inputUnit]) * group[outputUnit]).toPrecision( + DECIMAL_PRECISION + ) + ); + + try { + return new Intl.NumberFormat("en-US", { + style: "unit", + unit: outputUnit, + maximumFractionDigits: DECIMAL_PRECISION, + }).format(outputNumber); + } catch (e) {} + + return `${outputNumber} ${outputUnit}`; + } +} + +/** + * Returns the suitable units for the given two values. + * If could not found suitable unit, returns null. + * + * @param {string} inputUnit + * A set of units to convert, mapped to the `inputUnit` value on the return + * @param {string} outputUnit + * A set of units to convert, mapped to the `outputUnit` value on the return + * @returns {{ inputUnit: string, outputUnit: string }} The suitable units. + */ +function findUnitGroup(inputUnit, outputUnit) { + inputUnit = toSuitableUnit(inputUnit); + outputUnit = toSuitableUnit(outputUnit); + + const group = UNITS_GROUPS.find(ug => ug[inputUnit] && ug[outputUnit]); + + if (!group) { + return null; + } + + const inputValue = group[inputUnit]; + const outputValue = group[outputUnit]; + + return { + group, + inputUnit: typeof inputValue === "string" ? inputValue : inputUnit, + outputUnit: typeof outputValue === "string" ? outputValue : outputUnit, + }; +} + +function toSuitableUnit(unit) { + return CASE_SENSITIVE_UNITS.includes(unit) ? unit : unit.toLowerCase(); +} diff --git a/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs new file mode 100644 index 0000000000..5a78d20577 --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterTemperature.sys.mjs @@ -0,0 +1,124 @@ +/* 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/. */ + +const ABSOLUTE = ["celsius", "kelvin", "fahrenheit"]; +const ALIAS = ["c", "k", "f"]; +const UNITS = [...ABSOLUTE, ...ALIAS]; + +const NUMBER_REGEX = "-?\\d+(?:\\.\\d+)?\\s*"; +const UNIT_REGEX = "\\w+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${NUMBER_REGEX})(${UNIT_REGEX})(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${UNIT_REGEX})`, + "i" +); + +const DECIMAL_PRECISION = 10; + +/** + * This module converts temperature unit. + */ +export class UnitConverterTemperature { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const target = findUnits(regexResult[2], regexResult[3]); + + if (!target) { + return null; + } + + const { inputUnit, outputUnit } = target; + const inputNumber = Number(regexResult[1]); + const inputChar = inputUnit.charAt(0); + const outputChar = outputUnit.charAt(0); + + let outputNumber; + if (inputChar === outputChar) { + outputNumber = inputNumber; + } else { + outputNumber = this[`${inputChar}2${outputChar}`](inputNumber); + } + + outputNumber = parseFloat(outputNumber.toPrecision(DECIMAL_PRECISION)); + + try { + return new Intl.NumberFormat("en-US", { + style: "unit", + unit: outputUnit, + maximumFractionDigits: DECIMAL_PRECISION, + }).format(outputNumber); + } catch (e) {} + + return `${outputNumber} ${outputUnit}`; + } + + c2k(t) { + return t + 273.15; + } + + c2f(t) { + return t * 1.8 + 32; + } + + k2c(t) { + return t - 273.15; + } + + k2f(t) { + return this.c2f(this.k2c(t)); + } + + f2c(t) { + return (t - 32) / 1.8; + } + + f2k(t) { + return this.c2k(this.f2c(t)); + } +} + +/** + * Returns the suitable units for the given two values. + * If could not found suitable unit, returns null. + * + * @param {string} inputUnit + * A set of units to convert, mapped to the `inputUnit` value on the return + * @param {string} outputUnit + * A set of units to convert, mapped to the `outputUnit` value on the return + * @returns {{ inputUnit: string, outputUnit: string }} The suitable units. + */ +function findUnits(inputUnit, outputUnit) { + inputUnit = inputUnit.toLowerCase(); + outputUnit = outputUnit.toLowerCase(); + + if (!UNITS.includes(inputUnit) || !UNITS.includes(outputUnit)) { + return null; + } + + return { + inputUnit: toAbsoluteUnit(inputUnit), + outputUnit: toAbsoluteUnit(outputUnit), + }; +} + +function toAbsoluteUnit(unit) { + if (unit.length !== 1) { + return unit; + } + + return ABSOLUTE.find(a => a.startsWith(unit)); +} diff --git a/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs b/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs new file mode 100644 index 0000000000..50ba924bed --- /dev/null +++ b/browser/components/urlbar/unitconverters/UnitConverterTimezone.sys.mjs @@ -0,0 +1,148 @@ +/* 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/. */ + +const TIMEZONES = { + IDLW: -12, + NT: -11, + HST: -10, + AKST: -9, + PST: -8, + AKDT: -8, + MST: -7, + PDT: -7, + CST: -6, + MDT: -6, + EST: -5, + CDT: -5, + EDT: -4, + AST: -4, + GUY: -3, + ADT: -3, + AT: -2, + UTC: 0, + GMT: 0, + Z: 0, + WET: 0, + WEST: 1, + CET: 1, + BST: 1, + IST: 1, + CEST: 2, + EET: 2, + EEST: 3, + MSK: 3, + MSD: 4, + ZP4: 4, + ZP5: 5, + ZP6: 6, + WAST: 7, + AWST: 8, + WST: 8, + JST: 9, + ACST: 9.5, + ACDT: 10.5, + AEST: 10, + AEDT: 11, + NZST: 12, + IDLE: 12, + NZD: 13, +}; + +const TIME_REGEX = "([0-2]?[0-9])(:([0-5][0-9]))?\\s*([ap]m)?"; +const TIMEZONE_REGEX = "\\w+"; + +// NOTE: This regex need to be localized upon supporting multi locales +// since it supports en-US input format only. +const QUERY_REGEX = new RegExp( + `^(${TIME_REGEX}|now)\\s*(${TIMEZONE_REGEX})?(?:\\s+in\\s+|\\s+to\\s+|\\s*=\\s*)(${TIMEZONE_REGEX}|here)`, + "i" +); + +const KEYWORD_HERE = "HERE"; +const KEYWORD_NOW = "NOW"; + +/** + * This module converts timezone. + */ +export class UnitConverterTimezone { + /** + * Convert the given search string. + * + * @param {string} searchString + * The string to be converted + * @returns {string} conversion result. + */ + convert(searchString) { + const regexResult = QUERY_REGEX.exec(searchString); + if (!regexResult) { + return null; + } + + const inputTime = regexResult[1].toUpperCase(); + const inputTimezone = regexResult[6]?.toUpperCase(); + let outputTimezone = regexResult[7].toUpperCase(); + + if ( + (inputTimezone && + inputTimezone !== KEYWORD_NOW && + !(inputTimezone in TIMEZONES)) || + (outputTimezone !== KEYWORD_HERE && !(outputTimezone in TIMEZONES)) + ) { + return null; + } + + const inputDate = new Date(); + let isMeridiemNeeded = false; + if (inputTime === KEYWORD_NOW) { + inputDate.setUTCHours(inputDate.getHours()); + inputDate.setUTCMinutes(inputDate.getMinutes()); + } else { + // If the input was given as AM/PM, we need to convert it to 24h. + // 12AM is converted to 00, and for PM times we add 12 to the hour value except for 12PM. + // If the input is for example 23PM, we use 23 as the hour - we don't add 12 as this would result in a date increment. + const inputAMPM = regexResult[5]?.toLowerCase() || ""; + const inputHours = + regexResult[2] === "12" && inputAMPM === "am" + ? 0 + : Number(regexResult[2]); + const inputMinutes = regexResult[4] ? Number(regexResult[4]) : 0; + const inputMeridianHourShift = + inputAMPM === "pm" && inputHours < 12 ? 12 : 0; + inputDate.setUTCHours(inputHours + inputMeridianHourShift); + inputDate.setUTCMinutes(inputMinutes); + isMeridiemNeeded = !!inputAMPM; + } + + const inputOffset = inputTimezone + ? TIMEZONES[inputTimezone] * 60 + : -inputDate.getTimezoneOffset(); + let outputOffset; + if (outputTimezone === KEYWORD_HERE) { + outputOffset = -inputDate.getTimezoneOffset(); + const sign = -inputDate.getTimezoneOffset() > 0 ? "+" : "-"; + const hours = parseInt(Math.abs(outputOffset) / 60); + const minutes = formatMinutes((outputOffset % 60) * 60); + outputTimezone = `UTC${sign}${hours}${minutes}`; + } else { + outputOffset = TIMEZONES[outputTimezone] * 60; + } + + const outputDate = new Date(inputDate.getTime()); + outputDate.setUTCMinutes( + outputDate.getUTCMinutes() - inputOffset + outputOffset + ); + + const time = new Intl.DateTimeFormat("en-US", { + timeStyle: "short", + hour12: isMeridiemNeeded, + timeZone: "UTC", + }).format(outputDate); + + return `${time} ${outputTimezone}`; + } +} + +function formatMinutes(minutes) { + return minutes.toString().padStart(2, "0"); +} diff --git a/browser/components/urlbar/unitconverters/moz.build b/browser/components/urlbar/unitconverters/moz.build new file mode 100644 index 0000000000..649522658f --- /dev/null +++ b/browser/components/urlbar/unitconverters/moz.build @@ -0,0 +1,9 @@ +# 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/. + +EXTRA_JS_MODULES += [ + "UnitConverterSimple.sys.mjs", + "UnitConverterTemperature.sys.mjs", + "UnitConverterTimezone.sys.mjs", +] |