diff options
Diffstat (limited to 'browser/components/urlbar')
292 files changed, 71193 insertions, 0 deletions
diff --git a/browser/components/urlbar/.eslintrc.js b/browser/components/urlbar/.eslintrc.js new file mode 100644 index 0000000000..8218b7f089 --- /dev/null +++ b/browser/components/urlbar/.eslintrc.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/. */ + +"use strict"; + +module.exports = { + rules: { + "mozilla/var-only-at-top-level": "error", + "require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: false, + MethodDefinition: false, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "valid-jsdoc": [ + "error", + { + prefer: { + return: "returns", + }, + preferType: { + Boolean: "boolean", + Number: "number", + String: "string", + Object: "object", + bool: "boolean", + }, + requireParamDescription: false, + requireReturn: false, + requireReturnDescription: false, + }, + ], + "no-unused-expressions": "error", + }, +}; diff --git a/browser/components/urlbar/UrlbarController.jsm b/browser/components/urlbar/UrlbarController.jsm new file mode 100644 index 0000000000..15aad7c48b --- /dev/null +++ b/browser/components/urlbar/UrlbarController.jsm @@ -0,0 +1,899 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarController"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.jsm", + FormHistory: "resource://gre/modules/FormHistory.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS"; +const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS"; +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() + */ +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 || UrlbarProvidersManager; + + this._listeners = new Set(); + this._userSelectionBehavior = "none"; + + this.engagementEvent = new TelemetryEvent( + this, + options.eventTelemetryCategory + ); + } + + 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 handleDeleteEntry 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() { + // 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); + } + + 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.visibleElementCount, + 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(); + } + } + event.preventDefault(); + break; + 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. + // 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, we want to focus the next toolbar item + // instead, for accessibility reasons. + let allowTabbingThroughResults = + this.input.focusedViaMousedown || + this.input.searchMode?.isPreview || + (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_DOWN: + case KeyEvent.DOM_VK_UP: + case KeyEvent.DOM_VK_PAGE_DOWN: + case KeyEvent.DOM_VK_PAGE_UP: + if (event.ctrlKey || 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 + ? 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._handleDeleteEntry()) { + 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. + * @param {UrlbarResult} result Tthe result to speculative connect to. + * @param {UrlbarQueryContext} context The queryContext + * @param {string} reason Reason for the speculative connect request. + * @note speculative connect to: + * - Search engine heuristic results + * - autofill results + * - http/https results + */ + speculativeConnect(result, context, reason) { + // Never speculative connect in private contexts. + if (!this.input || context.isPrivate || !context.results.length) { + return; + } + let { url } = 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 == UrlbarUtils.RESULT_TYPE.SEARCH) { + // Speculative connect only if search suggestions are enabled. + if ( + UrlbarPrefs.get("suggest.searches") && + UrlbarPrefs.get("browser.search.suggest.enabled") + ) { + let engine = Services.search.getEngineByName( + result.payload.engine + ); + UrlbarUtils.setupSpeculativeConnection( + engine, + this.browserWindow + ); + } + } else if (result.autofill) { + UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow); + } + } + return; + } + case "mousedown": { + // On mousedown, connect only to http/https urls. + if (url.startsWith("http")) { + 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; + } + 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" + : 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 + ); + } + + // These histograms should be removed after a deprecation time where we'll + // confirm goodness of the new scalar above. + if (!(telemetryType in UrlbarUtils.SELECTED_RESULT_TYPES)) { + Cu.reportError(`Unsupported telemetry type ${telemetryType}`); + return; + } + Services.telemetry + .getHistogramById("FX_URLBAR_SELECTED_RESULT_INDEX") + .add(resultIndex); + Services.telemetry + .getHistogramById("FX_URLBAR_SELECTED_RESULT_TYPE_2") + .add(UrlbarUtils.SELECTED_RESULT_TYPES[telemetryType]); + Services.telemetry + .getKeyedHistogramById("FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2") + .add(telemetryType, resultIndex); + } + + /** + * Internal function handling deletion of entries. We only support removing + * of history entries - other result sources will be ignored. + * + * @returns {boolean} Returns true if the deletion was acted upon. + */ + _handleDeleteEntry() { + if (!this._lastQueryContextWrapper) { + Cu.reportError("Cannot delete - the latest query is not present"); + return false; + } + + const selectedResult = this.input.view.selectedResult; + if ( + !selectedResult || + selectedResult.source != UrlbarUtils.RESULT_SOURCE.HISTORY || + selectedResult.heuristic + ) { + return false; + } + + let { queryContext } = this._lastQueryContextWrapper; + let index = queryContext.results.indexOf(selectedResult); + if (index < 0) { + Cu.reportError("Failed to find the selected result in the results"); + return false; + } + + queryContext.results.splice(index, 1); + this.notify(NOTIFICATIONS.QUERY_RESULT_REMOVED, index); + + // Form history or url restyled as search. + if (selectedResult.type == UrlbarUtils.RESULT_TYPE.SEARCH) { + if (!queryContext.formHistoryName) { + return false; + } + // Generate the search url to remove it from browsing history. + let { url } = UrlbarUtils.getUrlFromResult(selectedResult); + PlacesUtils.history.remove(url).catch(Cu.reportError); + // Now remove form history. + FormHistory.update( + { + op: "remove", + fieldname: queryContext.formHistoryName, + value: selectedResult.payload.suggestion, + }, + { + handleError(error) { + Cu.reportError(`Removing form history failed: ${error}`); + }, + } + ); + return true; + } + + // Remove browsing history entries from Places. + PlacesUtils.history + .remove(selectedResult.payload.url) + .catch(Cu.reportError); + return true; + } + + /** + * 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) { + Cu.reportError(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; + } + + /** + * 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. + * @note This should never throw, or it may break the urlbar. + * @see the in-tree urlbar telemetry documentation. + */ + start(event, searchString = null) { + // 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. + if ( + this._startEventInfo && + this._startEventInfo.interactionType == "returned" && + (!searchString || this._startEventInfo.searchString[0] != searchString[0]) + ) { + 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. + if (!this._category || this._startEventInfo) { + return; + } + if (!event) { + Cu.reportError("Must always provide an event"); + return; + } + const validEvents = [ + "click", + "command", + "drop", + "input", + "keydown", + "mousedown", + "tabswitch", + "focus", + ]; + if (!validEvents.includes(event.type)) { + Cu.reportError("Can't start recording from event type: " + event.type); + return; + } + + let interactionType = "topsites"; + if (event.interactionType) { + interactionType = event.interactionType; + } else if (event.type == "input") { + interactionType = UrlbarUtils.isPasteEvent(event) ? "pasted" : "typed"; + } else if (event.type == "drop") { + interactionType = "dropped"; + } else if (searchString) { + interactionType = "typed"; + } + + this._startEventInfo = { + timeStamp: event.timeStamp || Cu.now(), + interactionType, + searchString, + }; + + this._controller.manager.notifyEngagementChange(this._isPrivate, "start"); + } + + /** + * 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. + * @param {event} [event] A DOM event. + * @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.selIndex Index of the selected result, undefined + * for "blur". + * @param {string} details.selType type of the selected element, undefined + * for "blur". One of "unknown", "autofill", "visiturl", "bookmark", + * "history", "keyword", "searchengine", "searchsuggestion", + * "switchtab", "remotetab", "extension", "oneoff". + * @param {string} details.provider The name of the provider for the selected + * result. + * @note event can be null, that usually happens for paste&go or drop&go. + * If there's no _startEventInfo this is a no-op. + */ + record(event, details) { + // This should never throw, or it may break the urlbar. + try { + this._internalRecord(event, details); + } catch (ex) { + Cu.reportError("Could not record event: " + ex); + } finally { + this._startEventInfo = null; + this._discarded = false; + } + } + + _internalRecord(event, details) { + if (!this._category || !this._startEventInfo) { + if (this._discarded && this._category) { + this._controller.manager.notifyEngagementChange( + this._isPrivate, + "discard" + ); + } + return; + } + if ( + !event && + this._startEventInfo.interactionType != "pasted" && + this._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 endTime = (event && event.timeStamp) || Cu.now(); + let startTime = this._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)); + + let action; + if (!event) { + action = + this._startEventInfo.interactionType == "dropped" + ? "drop_go" + : "paste_go"; + } else if (event.type == "blur") { + action = "blur"; + } else { + action = event instanceof MouseEvent ? "click" : "enter"; + } + let method = action == "blur" ? "abandonment" : "engagement"; + let value = this._startEventInfo.interactionType; + + // Rather than listening to the pref, just update status when we record an + // event, if the pref changed from the last time. + let recordingEnabled = UrlbarPrefs.get("eventTelemetry.enabled"); + if (this._eventRecordingEnabled != recordingEnabled) { + this._eventRecordingEnabled = recordingEnabled; + Services.telemetry.setEventRecordingEnabled("urlbar", recordingEnabled); + } + + // 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 extra = { + elapsed: elapsed.toString(), + numChars: details.searchString.length.toString(), + numWords: details.searchString + .trim() + .split(UrlbarTokenizer.REGEXP_SPACES) + .filter(t => t) + .length.toString(), + }; + 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, + value, + extra + ); + + this._controller.manager.notifyEngagementChange(this._isPrivate, method); + } + + /** + * 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() { + if (this._startEventInfo) { + this._startEventInfo = null; + this._discarded = true; + } + } + + /** + * Extracts a telemetry type from an element for event telemetry. + * @param {Element} element The element to analyze. + * @returns {string} a string type for the telemetry event. + */ + typeFromElement(element) { + if (!element) { + return "none"; + } + let row = element.closest(".urlbarView-row"); + if (row.result && row.result.providerName != "UrlbarProviderTopSites") { + // Element handlers go here. + if ( + row.result.type == UrlbarUtils.RESULT_TYPE.TIP && + element.classList.contains("urlbarView-tip-help") + ) { + return "tiphelp"; + } + } + // Now handle the result. + return UrlbarUtils.telemetryTypeFromResult(row.result); + } +} diff --git a/browser/components/urlbar/UrlbarEventBufferer.jsm b/browser/components/urlbar/UrlbarEventBufferer.jsm new file mode 100644 index 0000000000..3c43403b19 --- /dev/null +++ b/browser/components/urlbar/UrlbarEventBufferer.jsm @@ -0,0 +1,358 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarEventBufferer"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + clearTimeout: "resource://gre/modules/Timer.jsm", + Services: "resource://gre/modules/Services.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => + 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, + COMPLETE: 2, +}; + +/** + * 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. + */ +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) { + clearTimeout(this._deferringTimeout); + this._deferringTimeout = null; + } + } + + onQueryCancelled(queryContext) { + this._lastQuery.status = QUERY_STATUS.COMPLETE; + } + + onQueryFinished(queryContext) { + this._lastQuery.status = QUERY_STATUS.COMPLETE; + } + + onQueryResults(queryContext) { + // 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") { + 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) { + 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!`); + } + 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 = 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) { + // 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 && + !this._lastQuery.context.results.length; + 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.jsm b/browser/components/urlbar/UrlbarInput.jsm new file mode 100644 index 0000000000..b003b5ff1e --- /dev/null +++ b/browser/components/urlbar/UrlbarInput.jsm @@ -0,0 +1,3443 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarInput"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.jsm", + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + ReaderMode: "resource://gre/modules/ReaderMode.jsm", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarController: "resource:///modules/UrlbarController.jsm", + UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", + UrlbarValueFormatter: "resource:///modules/UrlbarValueFormatter.jsm", + UrlbarView: "resource:///modules/UrlbarView.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "ClipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +const DEFAULT_FORM_HISTORY_NAME = "searchbar-history"; +const SEARCH_BUTTON_ID = "urlbar-search-button"; + +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. + */ +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 = 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> + <hbox class="search-one-offs" + compact="true" + includecurrentengine="true" + disabletab="true"/> + </vbox> + `) + ); + this.panel = this.textbox.querySelector(".urlbarView"); + + this.searchButton = UrlbarPrefs.get("experimental.searchButton"); + if (this.searchButton) { + this.textbox.classList.add("searchButton"); + } + + this.controller = new UrlbarController({ + input: this, + eventTelemetryCategory: options.eventTelemetryCategory, + }); + this.view = new UrlbarView(this); + this.valueIsTyped = false; + this.formHistoryName = DEFAULT_FORM_HISTORY_NAME; + this.lastQueryContextPromise = Promise.resolve(); + this._actionOverrideKeyCount = 0; + this._autofillPlaceholder = ""; + 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) { + return (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 UrlbarValueFormatter(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 UrlbarEventBufferer(this); + + this._inputFieldEvents = [ + "compositionstart", + "compositionend", + "contextmenu", + "dragover", + "dragstart", + "drop", + "focus", + "blur", + "input", + "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(); + + // Tracks IME composition. + this._compositionState = 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; + } + + /** + * 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. + */ + setURI(uri = null, dueToTabSwitch = false) { + let value = this.window.gBrowser.userTypedValue; + let valid = false; + + // Explicitly check for nulled out value. We don't want to reset the URL + // bar if the user has deleted the URL and we'd just put the same URL + // back. See bug 304198. + if (value === null) { + uri = uri || 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) && + BrowserUtils.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"; + } + } + + valid = + !this.window.isBlankPageURL(uri.spec) || uri.schemeIs("moz-extension"); + } else if ( + this.window.isInitialPage(value) && + BrowserUtils.checkEmptyPageOrigin(this.window.gBrowser.selectedBrowser) + ) { + value = ""; + valid = true; + } + + let isDifferentValidValue = valid && value != this.untrimmedValue; + this.value = value; + this.valueIsTyped = !valid; + this.removeAttribute("usertyping"); + if (isDifferentValidValue) { + // The selection is enforced only for new values, to avoid overriding the + // cursor position when the user switches windows while typing. + this.selectionStart = this.selectionEnd = 0; + } + + // 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; + } + } + + /** + * 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 = 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 + */ + 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 = event instanceof this.window.MouseEvent; + 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 }); + } + + /** + * Handles an event which would cause a URL or text to be opened. + * + * @param {Event} [event] + * The event triggering the open. + * @param {object} [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 {string} oneOffParams.openWhere + * Where we expect the result to be opened. + * @param {object} oneOffParams.openParams + * The parameters related to where the result will be opened. + * @param {Node} oneOffParams.engine + * The selected one-off's engine. + * @param {object} [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 == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.inPrivateWindow; + let selectedPrivateEngineResult = + selectedPrivateResult && result.payload.isPrivateEngine; + if ( + !isComposing && + element && + (!oneOffParams?.engine || selectedPrivateEngineResult) + ) { + this.pickElement(element, 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(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] = UrlbarUtils.getSearchQueryUrl( + oneOffParams.engine, + searchString + ); + this._recordSearch(oneOffParams.engine, event, { url }); + + UrlbarUtils.addToFormHistory( + this, + searchString, + oneOffParams.engine.name + ).catch(Cu.reportError); + } 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, { + searchString: typedValue, + selIndex: this.view.selectedRowIndex, + selType, + provider: selectedResult?.providerName, + }); + + 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; + UrlbarUtils.getHeuristicResultFor(url) + .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() { + 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); + if (this.value && this.focused) { + this.select(); + } + } + + /** + * 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. + */ + pickResult( + result, + event, + element = null, + browser = this.window.gBrowser.selectedBrowser + ) { + // 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; + } + + let originalUntrimmedValue = this.untrimmedValue; + let isCanonized = this.setValueFromResult(result, event); + let where = this._whereToOpen(event); + let openParams = { + allowInheritPrincipal: false, + }; + + let selIndex = result.rowIndex; + if (!result.payload.providesSearchMode) { + this.view.close(/* elementPicked */ true); + } + + this.controller.recordSelectedResult(event, result); + + if (isCanonized) { + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "canonized", + provider: result.providerName, + }); + this._loadURL(this.value, event, where, openParams, browser); + return; + } + + let { url, postData } = UrlbarUtils.getUrlFromResult(result); + openParams.postData = postData; + + switch (result.type) { + case 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 && + UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") && + UrlbarUtils.looksLikeSingleWordHost(originalUntrimmedValue) + ) { + url = originalUntrimmedValue; + } + break; + } + case 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 UrlbarUtils.RESULT_TYPE.TAB_SWITCH: { + if (this.hasAttribute("actionoverride")) { + where = "current"; + break; + } + + this.handleRevert(); + let prevTab = this.window.gBrowser.selectedTab; + let loadOpts = { + adoptIntoActiveWindow: UrlbarPrefs.get( + "switchTabs.adoptIntoActiveWindow" + ), + }; + + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "tabswitch", + provider: result.providerName, + }); + + let switched = this.window.switchToTabHavingURI( + Services.io.newURI(url), + false, + loadOpts + ); + if (switched && prevTab.isEmpty) { + this.window.gBrowser.removeTab(prevTab); + } + return; + } + case UrlbarUtils.RESULT_TYPE.SEARCH: { + if (result.payload.providesSearchMode) { + let searchModeParams = this._searchModeForResult(result); + if (searchModeParams) { + this.searchMode = searchModeParams; + this.search(""); + } + return; + } + + if ( + !this.searchMode && + result.heuristic && + // If we asked the DNS earlier, avoid the post-facto check. + !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. + UrlbarPrefs.get("dnsResolveSingleWordsAfterSearch") > 0 && + this.window.gKeywordURIFixup && + 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 == 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) { + UrlbarUtils.addToFormHistory( + this, + result.payload.suggestion || result.payload.query, + engine.name + ).catch(Cu.reportError); + } + break; + } + case UrlbarUtils.RESULT_TYPE.TIP: { + let scalarName; + if (element.classList.contains("urlbarView-tip-help")) { + url = result.payload.helpUrl; + if (!url) { + Cu.reportError("helpUrl not specified"); + return; + } + scalarName = `${result.payload.type}-help`; + } else { + scalarName = `${result.payload.type}-picked`; + } + Services.telemetry.keyedScalarAdd("urlbar.tips", scalarName, 1); + if (!url) { + this.handleRevert(); + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "tip", + provider: result.providerName, + }); + let provider = UrlbarProvidersManager.getProvider( + result.providerName + ); + if (!provider) { + Cu.reportError(`Provider not found: ${result.providerName}`); + return; + } + provider.tryMethod("pickResult", result, element); + return; + } + break; + } + case UrlbarUtils.RESULT_TYPE.DYNAMIC: { + url = result.payload.url; + // 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(); + } + + let provider = UrlbarProvidersManager.getProvider(result.providerName); + if (!provider) { + Cu.reportError(`Provider not found: ${result.providerName}`); + return; + } + provider.tryMethod("pickResult", result, element); + + // If we won't be navigating, this is the end of the engagement. + if (!url || !result.payload.shouldNavigate) { + this.controller.engagementEvent.record(event, { + selIndex, + searchString: this._lastSearchString, + selType: this.controller.engagementEvent.typeFromElement(element), + provider: result.providerName, + }); + return; + } + break; + } + case UrlbarUtils.RESULT_TYPE.OMNIBOX: { + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: "extension", + provider: result.providerName, + }); + + // 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. + ExtensionSearchHandler.handleInputEntered( + result.payload.keyword, + result.payload.content, + where + ); + return; + } + } + + if (!url) { + throw new Error(`Invalid url for result ${JSON.stringify(result)}`); + } + + if (!this.isPrivate && !result.heuristic) { + // This should not interrupt the load anyway. + UrlbarUtils.addToInputHistory(url, this._lastSearchString).catch( + Cu.reportError + ); + } + + this.controller.engagementEvent.record(event, { + searchString: this._lastSearchString, + selIndex, + selType: this.controller.engagementEvent.typeFromElement(element), + provider: result.providerName, + }); + + if (result.payload.sendAttributionRequest) { + PartnerLinkAttribution.makeRequest({ + targetURL: result.payload.url, + source: "urlbar", + campaignID: Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ), + }); + } + + this._loadURL( + url, + event, + where, + openParams, + { + source: result.source, + type: result.type, + }, + 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 {UrlbarResult} [result] + * The result that was selected or picked, null if no result was selected. + * @param {Event} [event] + * The event that picked the result. + * @returns {boolean} + * Whether the value has been canonized + */ + setValueFromResult(result = null, event = 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 UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + this.setAttribute("actiontype", "switchtab"); + break; + case 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) { + let { value, selectionStart, selectionEnd } = result.autofill; + this._autofillValue(value, selectionStart, selectionEnd); + } + + 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 ( + result.type == UrlbarUtils.RESULT_TYPE.URL && + UrlbarPrefs.get("trimURLs") && + result.payload.url.startsWith(BrowserUtils.trimURLProtocol) + ) { + let fixupInfo = this._getURIFixupInfo( + BrowserUtils.trimURL(result.payload.url) + ); + if (fixupInfo?.keywordAsSent) { + allowTrim = false; + } + } + + if (!result.autofill) { + setValueAndRestoreActionType(this._getValueFromResult(result), 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.selectionEnd == this._autofillPlaceholder.length && + this.selectionStart == this._lastSearchString.length && + this._autofillPlaceholder + .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._setValue(this.window.gBrowser.userTypedValue, false); + } + + return false; + } + + /** + * Starts a query based on the current input value. + * + * @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: UrlbarPrefs.get("maxRichResults"), + searchString, + userContextId: this.window.gBrowser.selectedBrowser.getAttribute( + "usercontextid" + ), + currentPage: this.window.gBrowser.currentURI.spec, + formHistoryName: this.formHistoryName, + allowSearchSuggestions: + !event || + !UrlbarUtils.isPasteEvent(event) || + !event.data || + event.data.length <= UrlbarPrefs.get("maxCharsForSearchSuggestions"), + }; + + 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 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 {UrlbarUtils.WEB_ENGINE_NAMES} [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 tokens = value.trim().split(UrlbarTokenizer.REGEXP_SPACES); + // Enter search mode if the string starts with a restriction token. + let searchMode = UrlbarUtils.searchModeForToken(tokens[0]); + if (!searchMode && searchEngine) { + searchMode = { engineName: searchEngine.name }; + } + + if (searchMode) { + searchMode.entry = searchModeEntry; + this.searchMode = searchMode; + // Remove the restriction token/alias from the string to be searched for + // in search mode. + value = value.replace(tokens[0], ""); + if (UrlbarTokenizer.REGEXP_SPACES.test(value[0])) { + // If there was a trailing space after the restriction token/alias, + // remove it. + value = value.slice(1); + } + this.inputField.value = value; + this._revertOnBlurValue = this.value; + } else if (Object.values(UrlbarTokenizer.RESTRICT).includes(tokens[0])) { + this.searchMode = null; + // If the entire value is a restricted token, append a space. + if (Object.values(UrlbarTokenizer.RESTRICT).includes(value)) { + value += " "; + } + this.inputField.value = value; + this._revertOnBlurValue = this.value; + } else { + 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 = this.document.createEvent("UIEvents"); + event.initUIEvent("input", true, false, this.window, 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. + */ + removeHiddenFocus() { + this._hideFocus = false; + if (this.focused) { + this.setAttribute("focused", "true"); + this.startLayoutExtend(); + } + } + + /** + * 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) || + ObjectUtils.deepEqual(currentSearchMode, searchMode); + + // Exit search mode if the passed-in engine is invalid or hidden. + if (searchMode?.engineName) { + if (!Services.search.isInitialized) { + await Services.search.init(); + } + let 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 }; + if (source) { + searchMode.source = source; + } else if (UrlbarUtils.WEB_ENGINE_NAMES.has(engineName)) { + // 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 = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } else if (source) { + let sourceName = UrlbarUtils.getResultSourceName(source); + if (sourceName) { + searchMode = { source }; + } else { + Cu.reportError(`Unrecognized source: ${source}`); + } + } + + if (searchMode) { + searchMode.isPreview = isPreview; + if (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 { + BrowserSearchTelemetry.recordSearchMode(searchMode); + } catch (ex) { + Cu.reportError(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: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: 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) { + return 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(); + this.startLayoutExtend(); + } + + 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; + } + // The Urlbar is unfocused or reduce motion is on and the view is closed. + // gReduceMotion is accurate in most cases, but it is automatically set to + // true when windows are loaded. We check `prefers-reduced-motion: reduce` + // to ensure the user actually set prefers-reduced-motion. We check + // gReduceMotion first to save work in the common case of having + // prefers-reduced-motion disabled. + if ( + !this.view.isOpen && + (this.getAttribute("focused") != "true" || + (this.window.gReduceMotion && + this.window.matchMedia("(prefers-reduced-motion: reduce)").matches)) + ) { + return; + } + + if (Cu.isInAutomation) { + if (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 || + (this.getAttribute("focused") == "true" && + (!this.window.gReduceMotion || + !this.window.matchMedia("(prefers-reduced-motion: reduce)").matches)) + ) { + 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 {string} entry + * The search mode entry point. See setSearchMode documentation for details. + * @param {UrlbarResult} [result] + * The result to confirm. Defaults to the currently selected result. + * @param {boolean} [checkValue] + * If true, the trimmed input value must equal the result's keyword in order + * to enter search mode. + * @param {boolean} [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 SearchUtils.TOPIC_ENGINE_MODIFIED: { + switch (data) { + case SearchUtils.MODIFIED_TYPE.CHANGED: + case 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; + } + } + } + + // Private methods below. + + _addObservers() { + Services.obs.addObserver(this, 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) { + Cu.reportError( + `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 = 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) { + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.KEYWORD: + return result.payload.input; + case UrlbarUtils.RESULT_TYPE.SEARCH: { + let value = ""; + if (result.payload.keyword) { + value += result.payload.keyword + " "; + } + value += result.payload.suggestion || result.payload.query; + return value; + } + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return result.payload.content; + } + + try { + let uri = Services.io.newURI(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 = ""; + } + + /** + * 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. + */ + _maybeAutofillOnInput(value) { + // We allow autofill in local but not remote search modes. + let allowAutofill = + this.selectionEnd == value.length && + !this.searchMode?.engineName && + this.searchMode?.source != UrlbarUtils.RESULT_SOURCE.SEARCH; + + // 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. + if ( + !allowAutofill || + this._autofillPlaceholder.length <= value.length || + !this._autofillPlaceholder + .toLocaleLowerCase() + .startsWith(value.toLocaleLowerCase()) + ) { + this._autofillPlaceholder = ""; + } else if ( + this._autofillPlaceholder && + this.selectionEnd == this.value.length && + this._enableAutofillPlaceholder + ) { + let autofillValue = + value + this._autofillPlaceholder.substring(value.length); + this._autofillValue(autofillValue, value.length, autofillValue.length); + } + + return allowAutofill; + } + + _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") != "ltr" && + 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") && + !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(BrowserUtils.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 = BrowserUtils.trimURLProtocol + selectedVal; + } + + 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); + + BrowserSearchTelemetry.recordSearch( + this.window.gBrowser.selectedBrowser, + engine, + // 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. + this.searchMode && !isOneOff ? "urlbar-searchmode" : "urlbar", + { ...searchActionDetails, isOneOff } + ); + } + + /** + * 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) { + return UrlbarPrefs.get("trimURLs") ? BrowserUtils.trimURL(val) : val; + } + + /** + * 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 ( + !(event instanceof KeyboardEvent) || + event._disableCanonization || + !event.ctrlKey || + !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_FLAGS_MAKE_ALTERNATE_URI + ); + value = info.fixedURI.spec; + } catch (ex) { + Cu.reportError( + `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 {string} value + * The value to autofill. + * @param {integer} selectionStart + * The new selectionStart. + * @param {integer} selectionEnd + * The new selectionEnd. + */ + _autofillValue(value, selectionStart, selectionEnd) { + // 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.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + this._autofillPlaceholder = value; + } + + /** + * 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} [result.type] + * Details of the result type, if any. + * @param {UrlbarUtils.RESULT_SOURCE} [result.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 = url; + browser.userTypedValue = url; + } + + // 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 { + 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. + Cu.reportError(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.clearTemporaryPermissions(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 (event?.keyCode === KeyEvent.DOM_VK_RETURN) { + if (openUILinkWhere === "current") { + params.avoidBrowserFocus = true; + this._keyDownEnterDeferred?.resolve(browser); + } + } + + // 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.selectionStart = this.selectionEnd = 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(); + } + } + + this.view.close(); + } + + /** + * 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 = event instanceof KeyboardEvent; + 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 && + 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 (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); + } + + _initPasteAndGo() { + let inputBox = this.querySelector("moz-input-box"); + let contextMenu = inputBox.menupopup; + let insertLocation = contextMenu.firstElementChild; + while ( + insertLocation.nextElementSibling && + insertLocation.getAttribute("cmd") != "cmd_paste" + ) { + insertLocation = insertLocation.nextElementSibling; + } + 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.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 + * @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 = 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 } = 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"); + + 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, + UrlbarUtils.WEB_ENGINE_NAMES.has(engineName) + ? "urlbar-placeholder-search-mode-web-2" + : "urlbar-placeholder-search-mode-other-engine", + { name: engineName } + ); + } else if (source) { + let sourceName = 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.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 != 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 entering search + // mode from a one-off, then they are in the same engagement and we should + // not discard. + if ( + !event.target.classList.contains("searchbar-engine-one-off-item") || + this.searchMode?.entry != "oneoff" + ) { + this.controller.engagementEvent.discard(); + } + } + + _on_blur(event) { + this.focusedViaMousedown = false; + // 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, + }); + + this.removeAttribute("focused"); + this.endLayoutExtend(); + + 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 (ExtensionSearchHandler.hasActiveInputSession()) { + ExtensionSearchHandler.handleInputCancelled(); + } + + // Respect the autohide preference for easier inspecting/debugging via + // the browser toolbox. + if (!UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + + if (this._revertOnBlurValue == this.value) { + this.handleRevert(); + } + this._revertOnBlurValue = null; + + // 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) { + // Context menu opened via keyboard shortcut. + if (!event.button) { + return; + } + + this._maybeSelectAll(); + } + + _on_focus(event) { + if (!this._hideFocus) { + this.setAttribute("focused", "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; + } + } + + this.startLayoutExtend(); + + 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 (!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; + + 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.selectionStart = this.selectionEnd = 0; + } + + if (event.target.id == SEARCH_BUTTON_ID) { + this._preventClickSelectsAll = true; + this.search(UrlbarTokenizer.RESTRICT.SEARCH); + } else { + this.view.autoOpen({ event }); + } + 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 (!UrlbarPrefs.get("ui.popup.disable_autohide")) { + this.view.close(); + } + break; + } + } + + _on_input(event) { + 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 != UrlbarUtils.COMPOSITION.COMPOSING) { + this._compositionState = 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 && !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 ( + UrlbarPrefs.get("imeCompositionClosesPanel") && + (compositionState == UrlbarUtils.COMPOSITION.COMPOSING || + (compositionState == 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. + let allowAutofill = + !!event.data && + !UrlbarUtils.isPasteEvent(event) && + this._maybeAutofillOnInput(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.supportsSelectionClipboard() + ) { + return; + } + + let val = this._getSelectedValueForClipboard(); + if (!val) { + return; + } + + 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 pasteData = UrlbarUtils.stripUnsafeProtocolOnPaste(originalPasteData); + if (originalPasteData != pasteData) { + // Unfortunately we're not allowed to set the bits being pasted + // so cancel this event: + event.preventDefault(); + event.stopImmediatePropagation(); + + this.inputField.value = oldStart + pasteData + oldEnd; + // Fix up cursor/selection: + let newCursorPos = oldStart.length + pasteData.length; + this.selectionStart = newCursorPos; + this.selectionEnd = newCursorPos; + } + } + + _on_scrollend(event) { + this._updateTextOverflow(); + } + + _on_TabSelect(event) { + this._gotTabSelect = true; + this._afterTabSelectAndFocusChange(); + } + + _on_keydown(event) { + if (event.keyCode === KeyEvent.DOM_VK_RETURN) { + if (this._keyDownEnterDeferred) { + this._keyDownEnterDeferred.reject(); + } + this._keyDownEnterDeferred = 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_RETURN && + this._keyDownEnterDeferred + ) { + 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.selectionStart = this.selectionEnd = 0; + } + this._keyDownEnterDeferred = null; + } 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. + } + return; + } else if (event.keyCode === KeyEvent.DOM_VK_CONTROL) { + this._isKeyDownWithCtrl = false; + } + + this._toggleActionOverride(event); + } + + _on_compositionstart(event) { + if (this._compositionState == UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to start a nested composition?"); + } + this._compositionState = UrlbarUtils.COMPOSITION.COMPOSING; + + if (!UrlbarPrefs.get("imeCompositionClosesPanel")) { + 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 != UrlbarUtils.COMPOSITION.COMPOSING) { + throw new Error("Trying to stop a non existing composition?"); + } + + if (UrlbarPrefs.get("imeCompositionClosesPanel")) { + // 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 + ? UrlbarUtils.COMPOSITION.COMMIT + : 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 href = this.window.gBrowser.currentURI.displaySpec; + let title = this.window.gBrowser.contentTitle || href; + + event.dataTransfer.setData("text/x-moz-url", `${href}\n${title}`); + event.dataTransfer.setData("text/unicode", 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 = + droppedItem instanceof URL ? 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.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(); + } +} + +/** + * 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 (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, urlSecurityCheck 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 + ); + BrowserUtils.urlSecurityCheck( + url, + principal, + 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/unicode"); +} + +/** + * 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.selectionStart = urlbar.selectionEnd = start; + + let event = urlbar.document.createEvent("UIEvents"); + event.initUIEvent("input", true, false, urlbar.window, 0); + urlbar.inputField.dispatchEvent(event); + } + + ClipboardHelper.copyString(val); + } + + /** + * @param {string} command + * @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 + * @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() {} +} diff --git a/browser/components/urlbar/UrlbarMuxerUnifiedComplete.jsm b/browser/components/urlbar/UrlbarMuxerUnifiedComplete.jsm new file mode 100644 index 0000000000..dbd829f027 --- /dev/null +++ b/browser/components/urlbar/UrlbarMuxerUnifiedComplete.jsm @@ -0,0 +1,571 @@ +/* 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 module exports a component used to sort results in a UrlbarQueryContext. + */ + +var EXPORTED_SYMBOLS = ["UrlbarMuxerUnifiedComplete"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.jsm", + UrlbarMuxer: "resource:///modules/UrlbarUtils.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => + UrlbarUtils.getLogger({ prefix: "MuxerUnifiedComplete" }) +); + +function groupFromResult(result) { + if (result.heuristic) { + return UrlbarUtils.RESULT_GROUP.HEURISTIC; + } + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.payload.suggestion) { + return UrlbarUtils.RESULT_GROUP.SUGGESTION; + } + break; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + return UrlbarUtils.RESULT_GROUP.EXTENSION; + } + return UrlbarUtils.RESULT_GROUP.GENERAL; +} + +// Breaks ties among heuristic results. Providers higher up the list are higher +// priority. +const HEURISTIC_ORDER = [ + // Test providers are handled in sort(), + // Extension providers are handled in sort(), + "UrlbarProviderSearchTips", + "Omnibox", + "UnifiedComplete", + "Autofill", + "TokenAliasEngines", + "HeuristicFallback", +]; + +/** + * 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. + */ + sort(context) { + // 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, + resultsByGroup: new Map(), + totalResultCount: 0, + topHeuristicRank: Infinity, + strippedUrlToTopPrefixAndTitle: new Map(), + canShowPrivateSearch: context.results.length > 1, + canShowTailSuggestions: true, + formHistorySuggestions: new Set(), + canAddTabToSearch: true, + }; + + let resultsWithSuggestedIndex = []; + + // Do the first pass over all results to build some state. + for (let result of context.results) { + // Save results that have a suggested index for later. + if (result.suggestedIndex >= 0) { + resultsWithSuggestedIndex.push(result); + continue; + } + + // Add all other results to the resultsByGroup map: + // group => array of results belonging to the group + let group = groupFromResult(result); + let results = state.resultsByGroup.get(group); + if (!results) { + results = []; + state.resultsByGroup.set(group, results); + } + results.push(result); + + // Update pre-add state. + this._updateStatePreAdd(result, state); + } + + if ( + context.heuristicResult?.type == UrlbarUtils.RESULT_TYPE.SEARCH && + context.heuristicResult?.payload.query + ) { + state.heuristicResultQuery = context.heuristicResult.payload.query.toLocaleLowerCase(); + } + + // If the heuristic result is a search result, use search buckets, otherwise + // use normal buckets. + let buckets = + context.heuristicResult?.type == UrlbarUtils.RESULT_TYPE.SEARCH + ? UrlbarPrefs.get("matchBucketsSearch") + : UrlbarPrefs.get("matchBuckets"); + logger.debug(`Buckets: ${buckets}`); + + // Do the second pass to fill each bucket. We'll build a list where each + // item at index i is the array of results in the bucket at index i. + let resultsByBucketIndex = []; + for (let [group, maxResultCount] of buckets) { + let results = this._addResults(group, maxResultCount, state); + resultsByBucketIndex.push(results); + } + + // In search mode for an engine, search suggestions should always appear + // before general results. Transplanting them allows us to keep history + // results up to the limit set in matchBuckets, while filling the space + // above them with suggestions. + if (context.searchMode?.engineName) { + let suggestionsIndex = resultsByBucketIndex.findIndex( + results => + results[0] && + !results[0].heuristic && + results[0].type == UrlbarUtils.RESULT_TYPE.SEARCH + ); + if (suggestionsIndex > 1) { + logger.debug(`Transplanting suggestions before general results.`); + let removed = resultsByBucketIndex.splice(suggestionsIndex, 1); + resultsByBucketIndex.splice(1, 0, ...removed); + } + } + + // Build the sorted results list by concatenating each bucket's results. + let sortedResults = []; + let remainingCount = context.maxResults; + for (let i = 0; i < resultsByBucketIndex.length && remainingCount; i++) { + let results = resultsByBucketIndex[i]; + let count = Math.min(remainingCount, results.length); + sortedResults.push(...results.slice(0, count)); + remainingCount -= count; + } + + // Finally, insert results that have a suggested index. Sort them by index + // in descending order so that earlier insertions don't disrupt later ones. + resultsWithSuggestedIndex.sort( + (a, b) => a.suggestedIndex - b.suggestedIndex + ); + // Do a first pass to update sort state for each result. + for (let result of resultsWithSuggestedIndex) { + this._updateStatePreAdd(result, state); + } + // Now insert them. + for (let result of resultsWithSuggestedIndex) { + if (this._canAddResult(result, state)) { + let index = + result.suggestedIndex <= sortedResults.length + ? result.suggestedIndex + : sortedResults.length; + sortedResults.splice(index, 0, result); + this._updateStatePostAdd(result, state); + } + } + + context.results = sortedResults; + } + + /** + * Adds results to a bucket using results from the bucket's group in + * `state.resultsByGroup`. + * + * @param {string} group + * The bucket's group. + * @param {number} maxResultCount + * The maximum number of results to add to the bucket. + * @param {object} state + * Global state that we use to make decisions during this sort. + * @returns {array} + * The added results, empty if no results were added. + */ + _addResults(group, maxResultCount, state) { + let addedResults = []; + let groupResults = state.resultsByGroup.get(group); + while ( + groupResults?.length && + addedResults.length < maxResultCount && + state.totalResultCount < state.context.maxResults + ) { + // We either add or discard results in the order they appear in the + // groupResults array, so shift() them off. That way later buckets with + // the same group won't include results that earlier buckets have added or + // discarded. + let result = groupResults.shift(); + if (this._canAddResult(result, state)) { + addedResults.push(result); + state.totalResultCount++; + this._updateStatePostAdd(result, state); + } + } + return addedResults; + } + + /** + * Returns whether a result can be added to its bucket 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. + */ + _canAddResult(result, state) { + // Exclude low-ranked heuristic results. + if (result.heuristic && result != state.context.heuristicResult) { + return false; + } + + // We expect UnifiedComplete 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); + // We don't expect completely identical URLs in the results at this point, + // so if the prefixes are the same, then we're deduping a result against + // itself. + if (topPrefixData && prefix != topPrefixData.prefix) { + let prefixRank = UrlbarUtils.getPrefixRank(prefix); + if ( + prefixRank < topPrefixData.rank && + (prefix.endsWith("www.") == topPrefixData.prefix.endsWith("www.") || + result.payload?.title == topPrefixData.title) + ) { + return false; + } + } + } + + // Discard results that dupe autofill. + if ( + state.context.heuristicResult && + state.context.heuristicResult.providerName == "Autofill" && + result.providerName != "Autofill" && + state.context.heuristicResult.payload?.url == result.payload.url && + state.context.heuristicResult.type == result.type + ) { + 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 == "TabToSearch") { + // Discard the result if a tab-to-search result was added already. + if (!state.canAddTabToSearch) { + return false; + } + + if (!result.payload.satisfiesAutofillThreshold) { + // Discard the result if the heuristic result is not autofill. + if ( + state.context.heuristicResult.type != UrlbarUtils.RESULT_TYPE.URL || + !state.context.heuristicResult.autofill + ) { + 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 that dupes the heuristic or previous added form + // history (for restyleSearch). + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && + (result.payload.lowerCaseSuggestion === state.heuristicResultQuery || + state.formHistorySuggestions.has(result.payload.lowerCaseSuggestion)) + ) { + return false; + } + + // Discard remote search suggestions that dupe the heuristic. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.SEARCH && + result.payload.lowerCaseSuggestion && + result.payload.lowerCaseSuggestion === state.heuristicResultQuery + ) { + return false; + } + + // Discard tail suggestions if appropriate. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.payload.tail && + !state.canShowTailSuggestions + ) { + return false; + } + + // Discard SERPs from browser history that dupe either the heuristic or + // previously added form history. + 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.toLocaleLowerCase(); + if ( + state.heuristicResultQuery === resultQuery || + state.formHistorySuggestions.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, + resultQuery + ); + if ( + 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 = 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; + } + } + } + + // 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) { + // Determine the highest-ranking heuristic result. + if (result.heuristic) { + // + 2 to reserve the highest-priority slots for test and extension + // providers. + let heuristicRank = HEURISTIC_ORDER.indexOf(result.providerName) + 2; + // Extension and test provider names vary widely and aren't suitable + // for a static safelist like HEURISTIC_ORDER. + if (result.providerType == UrlbarUtils.PROVIDER_TYPE.EXTENSION) { + heuristicRank = 1; + } else if (result.providerName.startsWith("TestProvider")) { + heuristicRank = 0; + } else if (heuristicRank - 2 == -1) { + throw new Error( + `Heuristic result returned by unexpected provider: ${result.providerName}` + ); + } + // Replace in case of ties, which would occur if a provider sent two + // heuristic results. + if (heuristicRank <= state.topHeuristicRank) { + state.topHeuristicRank = heuristicRank; + state.context.heuristicResult = result; + } + } + + // Save some state we'll use later to dedupe URL results. + if (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 prefixRank = UrlbarUtils.getPrefixRank(prefix); + let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl); + let topPrefixRank = topPrefixData ? topPrefixData.rank : -1; + if (topPrefixRank < prefixRank) { + // strippedUrl => { prefix, title, rank } + state.strippedUrlToTopPrefixAndTitle.set(strippedUrl, { + prefix, + title: result.payload.title, + rank: prefixRank, + }); + } + } + + // 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; + } + } + + /** + * 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) { + // 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 ( + state.canShowPrivateSearch && + (result.type != UrlbarUtils.RESULT_TYPE.SEARCH || + result.payload.providesSearchMode || + (result.heuristic && result.payload.keyword)) + ) { + state.canShowPrivateSearch = false; + } + + // Update form history suggestions. + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == UrlbarUtils.RESULT_SOURCE.HISTORY + ) { + state.formHistorySuggestions.add(result.payload.lowerCaseSuggestion); + } + + // Avoid multiple tab-to-search results. + // TODO (Bug 1670185): figure out better strategies to manage this case. + if (result.providerName == "TabToSearch") { + 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 `onboardingEnginesShown` here. + if (result.payload.dynamicType) { + UrlbarProviderTabToSearch.onboardingEnginesShown.add( + result.payload.engine + ); + } + } + } +} + +var UrlbarMuxerUnifiedComplete = new MuxerUnifiedComplete(); diff --git a/browser/components/urlbar/UrlbarPrefs.jsm b/browser/components/urlbar/UrlbarPrefs.jsm new file mode 100644 index 0000000000..a73d2c959e --- /dev/null +++ b/browser/components/urlbar/UrlbarPrefs.jsm @@ -0,0 +1,467 @@ +/* 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 module exports the UrlbarPrefs singleton, which manages + * preferences for the urlbar. + */ + +var EXPORTED_SYMBOLS = ["UrlbarPrefs", "UrlbarPrefsObserver"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +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. +const PREF_URLBAR_DEFAULTS = new Map([ + // Whether we announce to screen readers when tab-to-search results are + // inserted. + ["accessibility.tabToSearch.announceResults", true], + + // "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], + + // 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 UnifiedComplete. + ["autoFill.stddevMultiplier", [0.0, "float"]], + + // 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], + + // 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", 1], + + // 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 urlbar displays a permanent search button. + ["experimental.searchButton", false], + + // Whether we style the search mode indicator's close button on hover. + ["experimental.searchModeIndicatorHover", false], + + // When we send events to extensions, we wait this amount of time in + // milliseconds for them to respond before timing out. + ["extension.timeout", 400], + + // 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], + + // Whether during IME composition the results panel should be closed. + ["imeCompositionClosesPanel", true], + + // Controls the composition of search results. + ["matchBuckets", "suggestion:4,general:Infinity"], + + // If the heuristic result is a search engine result, we use this instead of + // matchBuckets. + ["matchBucketsSearch", ""], + + // For search suggestion results, we truncate the user's search string to this + // number of characters before fetching results. + ["maxCharsForSearchSuggestions", 20], + + // The maximum number of form history results to include. + ["maxHistoricalSearchSuggestions", 0], + + // The maximum number of results in the urlbar popup. + ["maxRichResults", 10], + + // 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], + + // Whether speculative connections should be enabled. + ["speculativeConnect.enabled", true], + + // Whether results will include the user's bookmarks. + ["suggest.bookmark", 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 search suggestions. + ["suggest.searches", false], + + // Whether results will include search engines (e.g. tab-to-search). + ["suggest.engines", true], + + // Whether results will include top sites and the view will open on focus. + ["suggest.topsites", 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 redirect search tip. + ["tipShownCount.searchTip_redirect", 0], + + // Remove redundant portions from URLs. + ["trimURLs", true], + + // 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], +]); +const PREF_OTHER_DEFAULTS = new Map([ + ["keyword.enabled", true], + ["browser.search.suggest.enabled", true], + ["browser.search.suggest.enabled.private", false], + ["ui.popup.disable_autohide", false], + ["browser.fixup.dns_first_for_single_words", false], +]); + +// 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"], +]); + +// Buckets for result insertion. +// Every time a new result is returned, we go through each bucket in array order, +// and look for the first one having available space for the given result type. +// Each bucket is an array containing the following indices: +// 0: The result type of the acceptable entries. +// 1: available number of slots in this bucket. +// There are different matchBuckets definition for different contexts, currently +// a general one (matchBuckets) and a search one (matchBucketsSearch). +// +// First buckets. Anything with an Infinity frecency ends up here. +const DEFAULT_BUCKETS_BEFORE = [ + [UrlbarUtils.RESULT_GROUP.HEURISTIC, 1], + [ + UrlbarUtils.RESULT_GROUP.EXTENSION, + UrlbarUtils.MAXIMUM_ALLOWED_EXTENSION_MATCHES - 1, + ], +]; +// => USER DEFINED BUCKETS WILL BE INSERTED HERE <= +// +// Catch-all buckets. Anything remaining ends up here. +const DEFAULT_BUCKETS_AFTER = [ + [UrlbarUtils.RESULT_GROUP.SUGGESTION, Infinity], + [UrlbarUtils.RESULT_GROUP.GENERAL, Infinity], +]; + +/** + * 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); + } + + /** + * 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, setter } = this._getPrefDescriptor(pref); + if (typeof value != typeof defaultValue) { + throw new Error(`Invalid value type ${typeof value} for pref ${pref}`); + } + setter(pref, value); + } + + /** + * Adds a preference observer. Observers are held weakly. + * + * @param {object} observer + * An object that must have a method named `onPrefChanged`, which will + * be called when a urlbar preference changes. 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. + */ + addObserver(observer) { + this._observerWeakRefs.push(Cu.getWeakReference(observer)); + } + + /** + * Observes preference changes. + * + * @param {nsISupports} subject + * @param {string} topic + * @param {string} data + */ + observe(subject, topic, data) { + let pref = data.replace(PREF_URLBAR_BRANCH, ""); + if (!PREF_URLBAR_DEFAULTS.has(pref) && !PREF_OTHER_DEFAULTS.has(pref)) { + return; + } + 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); + } else { + observer.onPrefChanged(pref); + ++i; + } + } + } + + /** + * 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. + if (pref == "matchBuckets") { + this._map.delete("matchBucketsSearch"); + } + if (pref.startsWith("suggest.")) { + this._map.delete("defaultBehavior"); + } + } + + /** + * 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, getter } = this._getPrefDescriptor(pref); + return getter(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 "matchBuckets": { + // Convert from pref char format to an array and add the default + // buckets. + let val = this._readPref(pref); + try { + val = PlacesUtils.convertMatchBucketsStringToArray(val); + } catch (ex) { + val = PlacesUtils.convertMatchBucketsStringToArray( + PREF_URLBAR_DEFAULTS.get(pref) + ); + } + return [...DEFAULT_BUCKETS_BEFORE, ...val, ...DEFAULT_BUCKETS_AFTER]; + } + case "matchBucketsSearch": { + // Convert from pref char format to an array and add the default + // buckets. + let val = this._readPref(pref); + if (val) { + // Convert from pref char format to an array and add the default + // buckets. + try { + val = PlacesUtils.convertMatchBucketsStringToArray(val); + return [ + ...DEFAULT_BUCKETS_BEFORE, + ...val, + ...DEFAULT_BUCKETS_AFTER, + ]; + } catch (ex) { + /* invalid format, will just return matchBuckets */ + } + } + return this.get("matchBuckets"); + } + 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; + } + } + 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, getter, setter } + */ + _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) { + 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, + getter: branch[`get${type}Pref`], + // Float prefs are stored as Char. + setter: branch[`set${type == "Float" ? "Char" : type}Pref`], + }; + } +} + +var UrlbarPrefs = new Preferences(); diff --git a/browser/components/urlbar/UrlbarProviderAutofill.jsm b/browser/components/urlbar/UrlbarProviderAutofill.jsm new file mode 100644 index 0000000000..25d35c79e0 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderAutofill.jsm @@ -0,0 +1,801 @@ +/* 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 module exports a provider that provides an autofill result. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderAutofill"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + AboutPagesUtils: "resource://gre/modules/AboutPagesUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +// AutoComplete query type constants. +// Describes the various types of queries that we can process rows for. +const QUERYTYPE = { + FILTERED: 0, + AUTOFILL_ORIGIN: 1, + AUTOFILL_URL: 2, + ADAPTIVE: 3, +}; + +// `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. +const SQL_AUTOFILL_WITH = ` + 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"; + 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 frecency DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + TOTAL(frecency) OVER (PARTITION BY fixup_url(host)), + frecency, + 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 (host BETWEEN :searchString AND :searchString || X'FFFF') + OR (host BETWEEN 'www.' || :searchString AND 'www.' || :searchString || X'FFFF') + ) + SELECT :query_type AS query_type, + iif(instr(host, :searchString) = 1, host, fixed) || '/' AS host_fixed, + ifnull(:prefix, host_prefix) || host || '/' AS url + FROM origins + ${where} + ORDER BY frecency DESC, prefix = "https://" DESC, id DESC + LIMIT 1 + `; +} + +function urlQuery(where1, where2) { + // 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. + return `/* do not warn (bug no): cannot use an index to sort */ + SELECT :query_type AS query_type, + url, + :strippedURL AS stripped_url, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + id + FROM moz_places + WHERE rev_host = :revHost + ${where1} + UNION ALL + SELECT :query_type AS query_type, + url, + :strippedURL AS stripped_url, + frecency, + foreign_count > 0 AS bookmarked, + visit_count > 0 AS visited, + id + FROM moz_places + WHERE rev_host = :revHost || 'www.' + ${where2} + ORDER BY frecency DESC, id DESC + LIMIT 1 `; +} +// 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 strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (bookmarked OR frecency > 20) + AND strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'` +); + +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'` +); + +const QUERY_URL_HISTORY = urlQuery( + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND (visited OR NOT bookmarked) + AND frecency > 20 + AND strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'` +); + +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'` +); + +const QUERY_URL_BOOKMARK = urlQuery( + `AND bookmarked + AND strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN :strippedURL AND :strippedURL || X'FFFF'`, + `AND bookmarked + AND strip_prefix_and_userinfo(url) COLLATE NOCASE + BETWEEN 'www.' || :strippedURL AND 'www.' || :strippedURL || X'FFFF'` +); + +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'` +); + +const kProtocolsWithIcons = [ + "chrome:", + "moz-extension:", + "about:", + "http:", + "https:", + "ftp:", +]; +function iconHelper(url) { + if (typeof url == "string") { + return kProtocolsWithIcons.some(p => url.startsWith(p)) + ? "page-icon:" + url + : UrlbarUtils.ICON.DEFAULT; + } + if (url && url instanceof URL && kProtocolsWithIcons.includes(url.protocol)) { + return "page-icon:" + url.href; + } + return UrlbarUtils.ICON.DEFAULT; +} + +/** + * 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 (!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 == UrlbarTokenizer.TYPE.RESTRICT_TAG || + t.type == UrlbarTokenizer.TYPE.RESTRICT_TITLE + ) + ) { + return false; + } + + [this._strippedPrefix, this._searchString] = UrlbarUtils.stripURLPrefix( + queryContext.searchString + ); + this._strippedPrefix = this._strippedPrefix.toLowerCase(); + + if (!this._searchString || !this._searchString.length) { + return false; + } + + // 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 (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} Resolved when the filtering is complete. + * @resolves {string} The top matching host, or null if not found. + */ + async getTopHostOverThreshold(queryContext, hosts) { + let db = await PlacesUtils.promiseLargeCacheDBConnection(); + let conditions = []; + // Pay attention to the order of params, since they are not named. + let params = [UrlbarPrefs.get("autoFill.stddevMultiplier"), ...hosts]; + 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 frecency DESC, prefix = "https://" DESC, id DESC + ), + host, + fixup_url(host), + TOTAL(frecency) OVER (PARTITION BY fixup_url(host)), + frecency, + 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 + * @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(), + stddevMultiplier: 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 + * @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, + 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"); + } + + /** + * Processes a matched row in the Places database. + * @param {object} row + * The matched row. + * @param {UrlbarQueryContext} queryContext + * @returns {UrlbarResult} a result generated from the matches row. + */ + _processRow(row, queryContext) { + let queryType = row.getResultByName("query_type"); + let autofilledValue, finalCompleteValue; + switch (queryType) { + case QUERYTYPE.AUTOFILL_ORIGIN: + autofilledValue = row.getResultByName("host_fixed"); + finalCompleteValue = row.getResultByName("url"); + break; + case QUERYTYPE.AUTOFILL_URL: + let url = row.getResultByName("url"); + let strippedURL = row.getResultByName("stripped_url"); + // 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 + ); + if (nextSlashIndex == -1) { + autofilledValue = url.substr(strippedURLIndex); + } else { + autofilledValue = url.substring(strippedURLIndex, nextSlashIndex + 1); + } + finalCompleteValue = strippedPrefix + autofilledValue; + break; + } + + let [title] = UrlbarUtils.stripPrefixAndTrim(finalCompleteValue, { + stripHttp: true, + trimEmptyQuery: true, + trimSlash: !this._searchString.includes("/"), + }); + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + url: [finalCompleteValue, UrlbarUtils.HIGHLIGHT.TYPED], + icon: iconHelper(finalCompleteValue), + }) + ); + autofilledValue = + queryContext.searchString + + autofilledValue.substring(this._searchString.length); + result.autofill = { + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + 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 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [trimmedUrl, UrlbarUtils.HIGHLIGHT.TYPED], + url: [aboutUrl, UrlbarUtils.HIGHLIGHT.TYPED], + icon: iconHelper(aboutUrl), + }) + ); + let autofilledValue = + queryContext.searchString + + aboutUrl.substring(queryContext.searchString.length); + result.autofill = { + value: autofilledValue, + selectionStart: queryContext.searchString.length, + selectionEnd: autofilledValue.length, + }; + return result; + } + } + return null; + } + + async _matchKnownUrl(queryContext) { + let conn = await PlacesUtils.promiseLargeCacheDBConnection(); + if (!conn) { + 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 ( + 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 (!UrlbarPrefs.get("autoFill.searchEngines")) { + 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 ( + !UrlbarTokenizer.looksLikeOrigin(searchStr, { ignoreKnownDomains: true }) + ) { + return null; + } + + // Since we are autofilling, we can only pick one matching engine. Use the + // first. + let engine = (await UrlbarSearchUtils.enginesForDomainPrefix(searchStr))[0]; + if (!engine) { + return null; + } + let url = engine.searchForm; + let domain = engine.getResultDomain(); + // 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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; + } +} + +var UrlbarProviderAutofill = new ProviderAutofill(); diff --git a/browser/components/urlbar/UrlbarProviderExtension.jsm b/browser/components/urlbar/UrlbarProviderExtension.jsm new file mode 100644 index 0000000000..58b78ddb42 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderExtension.jsm @@ -0,0 +1,390 @@ +/* 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 module exports a provider class that is used for providers created by + * extensions. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderExtension"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + SkippableTimer: "resource:///modules/UrlbarUtils.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * 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. + */ +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 = UrlbarProvidersManager.getProvider(name); + if (!provider) { + provider = new UrlbarProviderExtension(name); + 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. + */ + get name() { + return this._name; + } + + /** + * The provider's type. The type of extension providers is always + * UrlbarUtils.PROVIDER_TYPE.EXTENSION. + */ + 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) { + 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", 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", context); + if (extResults) { + for (let extResult of extResults) { + let result = await this._makeUrlbarResult(context, extResult).catch( + Cu.reportError + ); + 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", context); + } + + /** + * This method is called when a result from the provider without a URL is + * picked, but currently only for tip results. The provider should handle the + * pick. + * + * @param {UrlbarResult} result + * The result that was picked. + * @param {Element} element + * The element in the result's view that was picked. + */ + pickResult(result, element) { + let dynamicElementName = ""; + if (element && result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + dynamicElementName = element.getAttribute("name"); + } + this._notifyListener("resultPicked", result.payload, dynamicElementName); + } + + /** + * This method is 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: start, engagement, abandonment, + * discard. + */ + onEngagement(isPrivate, state) { + 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) { + Cu.reportError(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: UrlbarPrefs.get("extension.timeout"), + reportErrorOnTimeout: true, + logger: this.logger, + }); + result = await Promise.race([ + timer.promise, + result.catch(Cu.reportError), + ]); + 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 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 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 = extResult.payload.type || "extension"; + } + + let result = new UrlbarResult( + UrlbarProviderExtension.RESULT_TYPES[extResult.type], + UrlbarProviderExtension.SOURCE_TYPES[extResult.source], + ...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 + // UnifiedComplete's 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, +}; diff --git a/browser/components/urlbar/UrlbarProviderHeuristicFallback.jsm b/browser/components/urlbar/UrlbarProviderHeuristicFallback.jsm new file mode 100644 index 0000000000..3ddf7f95ec --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderHeuristicFallback.jsm @@ -0,0 +1,326 @@ +/* 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 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. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderHeuristicFallback"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * 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 ( + UrlbarPrefs.get("keyword.enabled") && + (UrlbarTokenizer.looksLikeOrigin(str, { + noIp: true, + noPort: true, + }) || + 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 || + UrlbarTokenizer.SEARCH_MODE_RESTRICT.has( + queryContext.restrictToken?.value + ) || + queryContext.searchMode + ) { + return null; + } + + let unescapedSearchString = Services.textToSubURI.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 && + !UrlbarPrefs.get("keyword.enabled") + ) { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [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 (!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 (!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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...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 = Services.search.getEngineByName( + queryContext.searchMode.engineName + ); + } else { + engine = 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 === UrlbarTokenizer.RESTRICT.SEARCH + ) { + query = UrlbarUtils.substringAfter( + query, + queryContext.tokens[0].value + ).trim(); + } + + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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, + }) + ); + } +} + +var UrlbarProviderHeuristicFallback = new ProviderHeuristicFallback(); diff --git a/browser/components/urlbar/UrlbarProviderInterventions.jsm b/browser/components/urlbar/UrlbarProviderInterventions.jsm new file mode 100644 index 0000000000..d303da0f49 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderInterventions.jsm @@ -0,0 +1,810 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarProviderInterventions", "QueryScorer"]; +var gGlobalScope = this; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppUpdater: "resource:///modules/AppUpdater.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + NLP: "resource://gre/modules/NLP.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + ResetProfile: "resource://gre/modules/ResetProfile.jsm", + Sanitizer: "resource:///modules/Sanitizer.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "appUpdater", () => new 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. + */ +class QueryScorer { + /** + * @param {number} distanceThreshold + * Edit distances no larger than this value are considered matches. + * @param {Map} 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 {array} queryWords + * The query being scored, split into words. + * @param {Node} node + * The node currently being visited. + * @param {Map} minDistanceByDoc + * Keeps track of the minimum edit distance for each document as the + * traversal continues. + * @param {number} queryWordsIndex + * The current index in the query words array. + * @param {number} 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 = 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 l10n values for each tip's payload. + * @param {string} tip a value from the TIPS enum + * @returns {object} an Object shaped as { textData, buttonTextData, helpUrl } + */ +function getL10nPropertiesForTip(tip) { + const baseURL = "https://support.mozilla.org/kb/"; + switch (tip) { + case TIPS.CLEAR: + return { + textData: { id: "intervention-clear-data" }, + buttonTextData: { id: "intervention-clear-data-confirm" }, + helpUrl: baseURL + "delete-browsing-search-download-history-firefox", + }; + case TIPS.REFRESH: + return { + textData: { id: "intervention-refresh-profile" }, + buttonTextData: { id: "intervention-refresh-profile-confirm" }, + helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings", + }; + case TIPS.UPDATE_ASK: + return { + textData: { id: "intervention-update-ask" }, + buttonTextData: { id: "intervention-update-ask-confirm" }, + helpUrl: baseURL + "update-firefox-latest-release", + }; + case TIPS.UPDATE_REFRESH: + return { + textData: { id: "intervention-update-refresh" }, + buttonTextData: { id: "intervention-update-refresh-confirm" }, + helpUrl: baseURL + "refresh-firefox-reset-add-ons-and-settings", + }; + case TIPS.UPDATE_RESTART: + return { + textData: { id: "intervention-update-restart" }, + buttonTextData: { id: "intervention-update-restart-confirm" }, + helpUrl: baseURL + "update-firefox-latest-release", + }; + case TIPS.UPDATE_WEB: + return { + textData: { id: "intervention-update-web" }, + buttonTextData: { 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. + */ + get TIP_TYPE() { + return TIPS; + } + + /** + * Unique name for the provider, used by the context to filter on providers. + */ + get name() { + return "UrlbarProviderInterventions"; + } + + /** + * The type of the provider, must be one of 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 || + !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 = BrowserWindowTracker.getTopWindow(); + if (!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 (appUpdater.status) { + case AppUpdater.STATUS.READY_FOR_RESTART: + // Prompt the user to restart. + this.currentTip = TIPS.UPDATE_RESTART; + break; + case 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 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 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; + 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 = () => { + appUpdater.removeListener(this._appUpdaterListener); + delete this._appUpdaterListener; + resolve(); + }; + 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: this.currentTip, + } + ); + + result.suggestedIndex = 1; + + Object.assign(result.payload, getL10nPropertiesForTip(this.currentTip)); + + if (instance != this.queryInstance) { + return; + } + + 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) { + appUpdater.removeListener(this._appUpdaterListener); + delete this._appUpdaterListener; + } + } + + /** + * Called when a result from the provider without a URL is picked, but + * currently only for tip results. The provider should handle the pick. + * @param {UrlbarResult} result + * The result that was picked. + */ + pickResult(result) { + let tip = result.payload.type; + + // Do the tip action. + switch (tip) { + case TIPS.CLEAR: + openClearHistoryDialog(); + break; + case TIPS.REFRESH: + case TIPS.UPDATE_REFRESH: + resetBrowser(); + break; + case TIPS.UPDATE_ASK: + installBrowserUpdateAndRestart(); + break; + case TIPS.UPDATE_RESTART: + restartBrowser(); + break; + case TIPS.UPDATE_WEB: + let window = BrowserWindowTracker.getTopWindow(); + window.gBrowser.selectedTab = window.gBrowser.addWebTab( + "https://www.mozilla.org/firefox/new/" + ); + break; + } + } + + onEngagement(isPrivate, state) { + 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(); + 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(gGlobalScope, "appUpdater").get) { + appUpdater = new AppUpdater(); + } + } +} + +var UrlbarProviderInterventions = new ProviderInterventions(); + +/** + * Tip callbacks follow. + */ + +function installBrowserUpdateAndRestart() { + if (appUpdater.status != AppUpdater.STATUS.DOWNLOAD_AND_INSTALL) { + return Promise.resolve(); + } + return new Promise(resolve => { + let listener = () => { + // Once we call startDownload, there are two possible end + // states: DOWNLOAD_FAILED and READY_FOR_RESTART. + if ( + appUpdater.status != AppUpdater.STATUS.READY_FOR_RESTART && + appUpdater.status != AppUpdater.STATUS.DOWNLOAD_FAILED + ) { + return; + } + appUpdater.removeListener(listener); + if (appUpdater.status == AppUpdater.STATUS.READY_FOR_RESTART) { + restartBrowser(); + } + resolve(); + }; + appUpdater.addListener(listener); + appUpdater.startDownload(); + }); +} + +function openClearHistoryDialog() { + let window = BrowserWindowTracker.getTopWindow(); + // The behaviour of the Clear Recent History dialog in PBM does + // not have the expected effect (bug 463607). + if (PrivateBrowsingUtils.isWindowPrivate(window)) { + return; + } + 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() { + if (!ResetProfile.resetSupported()) { + return; + } + let window = BrowserWindowTracker.getTopWindow(); + ResetProfile.openConfirmationDialog(window); +} diff --git a/browser/components/urlbar/UrlbarProviderOmnibox.jsm b/browser/components/urlbar/UrlbarProviderOmnibox.jsm new file mode 100644 index 0000000000..86399316f4 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderOmnibox.jsm @@ -0,0 +1,178 @@ +/* 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 module exports a provider class that is used for providers created by + * extensions using the `omnibox` API. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderOmnibox"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.jsm", + SkippableTimer: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +// After this time, we'll give up waiting for the extension to return matches. +const MAXIMUM_ALLOWED_EXTENSION_TIME_MS = 3000; + +/** + * 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 && + 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 (ExtensionSearchHandler.hasActiveInputSession()) { + 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 = ExtensionSearchHandler.getDescription(keyword); + let heuristicResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + ...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 = 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [suggestion.description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [content, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [ + queryContext.tokens[0].value, + UrlbarUtils.HIGHLIGHT.TYPED, + ], + 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: MAXIMUM_ALLOWED_EXTENSION_TIME_MS, + logger: this.logger, + }).promise; + await Promise.race([timeoutPromise, this._resultsPromise]).catch( + Cu.reportError + ); + } +} + +var UrlbarProviderOmnibox = new ProviderOmnibox(); diff --git a/browser/components/urlbar/UrlbarProviderOpenTabs.jsm b/browser/components/urlbar/UrlbarProviderOpenTabs.jsm new file mode 100644 index 0000000000..8a870bb2c1 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderOpenTabs.jsm @@ -0,0 +1,215 @@ +/* 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 module exports a provider, returning open tabs matches for the urlbar. + * It is also used to register and unregister open tabs. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderOpenTabs"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * Class used to create the provider. + */ +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 UnifiedComplete. + 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(); + + /** + * Copy over cached open tabs to the memory table once the Urlbar + * connection has been initialized. + */ + static promiseDBPopulated = 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(Cu.reportError); + } + } + } + ); + + /** + * Registers a tab as open. + * @param {string} url Address of the tab + * @param {integer} userContextId Containers user context id + */ + static async registerOpenTab(url, userContextId = 0) { + if (!UrlbarProviderOpenTabs.openTabs.has(userContextId)) { + UrlbarProviderOpenTabs.openTabs.set(userContextId, []); + } + UrlbarProviderOpenTabs.openTabs.get(userContextId).push(url); + await addToMemoryTable(url, userContextId).catch(Cu.reportError); + } + + /** + * Unregisters a previously registered open tab. + * @param {string} url Address of the tab + * @param {integer} userContextId Containers user context id + */ + static async unregisterOpenTab(url, userContextId = 0) { + 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(Cu.reportError); + } + } + } + + /** + * 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 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 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 UrlbarProvidersManager.runInCriticalSection(async () => { + let conn = await 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 UrlbarProvidersManager.runInCriticalSection(async () => { + let conn = await 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/UrlbarProviderPrivateSearch.jsm b/browser/components/urlbar/UrlbarProviderPrivateSearch.jsm new file mode 100644 index 0000000000..713fb02ae9 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderPrivateSearch.jsm @@ -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/. */ + +"use strict"; + +/** + * This module exports a provider returning a private search entry. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderPrivateSearch"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + SkippableTimer: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * 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 ( + 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 == 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 != 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 = + 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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); + } +} + +var UrlbarProviderPrivateSearch = new ProviderPrivateSearch(); diff --git a/browser/components/urlbar/UrlbarProviderSearchSuggestions.jsm b/browser/components/urlbar/UrlbarProviderSearchSuggestions.jsm new file mode 100644 index 0000000000..4a2cbb5e78 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchSuggestions.jsm @@ -0,0 +1,524 @@ +/* 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 module exports a provider that offers search engine suggestions. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderSearchSuggestions"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.jsm", + Services: "resource://gre/modules/Services.jsm", + SkippableTimer: "resource:///modules/UrlbarUtils.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * Returns whether the passed in string looks like a url. + * @param {string} str + * @param {boolean} [ignoreAlphanumericHosts] + * @returns {boolean} + * True if the query looks like a URL. + */ +function looksLikeUrl(str, ignoreAlphanumericHosts = false) { + // Single word including special chars. + return ( + !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. + if ( + !queryContext.trimmedSearchString && + !this._isTokenOrRestrictionPresent(queryContext) + ) { + return false; + } + + if (!this._allowSuggestions(queryContext)) { + return false; + } + + let wantsLocalSuggestions = + UrlbarPrefs.get("maxHistoricalSearchSuggestions") && + (queryContext.trimmedSearchString || + 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 == 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 ( + !queryContext.allowSearchSuggestions || + // If the user typed a restriction token or token alias, we ignore the + // pref to disable suggestions in the Urlbar. + (!UrlbarPrefs.get("suggest.searches") && + !this._isTokenOrRestrictionPresent(queryContext)) || + !UrlbarPrefs.get("browser.search.suggest.enabled") || + (queryContext.isPrivate && + !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 + ) { + // TODO (Bug 1626964): Support zero prefix suggestions. + 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; + } + + // We're unlikely to get useful remote suggestions for a single character. + if (searchString.length < 2) { + return false; + } + + // Disallow remote suggestions if only an origin is typed to avoid + // disclosing information about sites the user visits. This also catches + // partially-typed origins, like mozilla.o, because the URIFixup check + // below can't validate those. + if ( + queryContext.tokens.length == 1 && + queryContext.tokens[0].type == UrlbarTokenizer.TYPE.POSSIBLE_ORIGIN + ) { + return false; + } + + // Disallow remote suggestions for strings containing tokens that look like + // URIs, to avoid disclosing information about networks or passwords. + if (queryContext.fixupInfo?.href && !queryContext.fixupInfo?.isSearch) { + return false; + } + + // Allow remote suggestions. + 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) { + 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 ( + UrlbarTokenizer.isRestrictionToken(queryContext.tokens[0]) && + (queryContext.tokens.length > 1 || + queryContext.tokens[0].type == 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 === 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 = Services.search.getEngineByName( + queryContext.searchMode.engineName + ); + } else { + engine = 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) { + return 0; + } + + /** + * Cancels a running query. + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + if (this._suggestionsController) { + this._suggestionsController.stop(); + this._suggestionsController = null; + } + } + + async _fetchSearchSuggestions(queryContext, engine, searchString, alias) { + if (!engine) { + return null; + } + + this._suggestionsController = new SearchSuggestionController(); + this._suggestionsController.formHistoryParam = 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 + // maxHistoricalSearchSuggestions + 1 form history entries. + let allowRemote = this._allowRemoteSuggestions(queryContext, searchString); + this._suggestionsController.maxRemoteResults = allowRemote + ? queryContext.maxResults + 1 + : 0; + + this._suggestionsFetchCompletePromise = this._suggestionsController.fetch( + searchString, + queryContext.isPrivate, + engine, + queryContext.userContextId, + this._isTokenOrRestrictionPresent(queryContext), + false + ); + + // See `SearchSuggestionsController.fetch` documentation for a description + // of `fetchData`. + let fetchData = await this._suggestionsFetchCompletePromise; + // The fetch was canceled. + if (!fetchData) { + return null; + } + + let results = []; + + // We use this to discard remote suggestions that duplicate form history. + let seenSuggestions = new Set(); + + // Add maxHistoricalSearchSuggestions form history results to the beginning + // of the array. Below we'll add the remainder after remote suggestions. + // Any excess results that are not discarded by the muxer will appear below + // the remote suggestions in the final results. + let maxInitialFormHistory = UrlbarPrefs.get( + "maxHistoricalSearchSuggestions" + ); + while (results.length < maxInitialFormHistory && fetchData.local.length) { + let entry = fetchData.local.shift(); + results.push(makeFormHistoryResult(queryContext, engine, entry)); + seenSuggestions.add(entry.value); + } + + // 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 > 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) || seenSuggestions.has(entry.value)) { + continue; + } + + if (entry.tail && entry.tailOffsetIndex < 0) { + Cu.reportError( + `Error in tail suggestion parsing. Value: ${entry.value}, tail: ${entry.tail}.` + ); + continue; + } + + let tail = entry.tail; + let tailPrefix = entry.matchPrefix; + + // Skip tail suggestions if the pref is disabled. + if (tail && !UrlbarPrefs.get("richSuggestions.tail")) { + continue; + } + + if (!tail) { + await tailTimer.fire().catch(Cu.reportError); + } + + try { + results.push( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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], + query: [searchString.trim(), UrlbarUtils.HIGHLIGHT.NONE], + icon: !entry.value ? engine.iconURI?.spec : undefined, + }) + ) + ); + seenSuggestions.add(entry.value); + } catch (err) { + Cu.reportError(err); + continue; + } + } + + // Add the remaining form history results. maxHistoricalSearchSuggestions + // == 0 is an opt-out mechanism, so do this only if it's non-zero. + while ( + maxInitialFormHistory && + results.length < queryContext.maxResults + 1 && + fetchData.local.length + ) { + let entry = fetchData.local.shift(); + if (!seenSuggestions.has(entry.value)) { + results.push(makeFormHistoryResult(queryContext, engine, entry)); + } + } + + await tailTimer.promise; + return results; + } + + /** + * Searches for an engine alias given the queryContext. + * @param {UrlbarQueryContext} queryContext + * @returns {object} aliasEngine + * A representation of the aliased engine. Null if there's no match. + * @returns {nsISearchEngine} aliasEngine.engine + * @returns {string} aliasEngine.alias + * @returns {string} aliasEngine.query + * @returns {object} { engine, alias, query } + * + */ + 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 (!UrlbarTokenizer.REGEXP_SPACES_START.test(query)) { + return null; + } + + // Check if the user entered an engine alias directly. + let engineMatch = await UrlbarSearchUtils.engineForAlias(possibleAlias); + if (engineMatch) { + return { + engine: engineMatch, + alias: possibleAlias, + query: query.trim(), + }; + } + + return null; + } +} + +function makeFormHistoryResult(queryContext, engine, entry) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + engine: engine.name, + suggestion: [entry.value, UrlbarUtils.HIGHLIGHT.SUGGESTED], + lowerCaseSuggestion: entry.value.toLocaleLowerCase(), + }) + ); +} + +var UrlbarProviderSearchSuggestions = new ProviderSearchSuggestions(); diff --git a/browser/components/urlbar/UrlbarProviderSearchTips.jsm b/browser/components/urlbar/UrlbarProviderSearchTips.jsm new file mode 100644 index 0000000000..3ee8194f2d --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderSearchTips.jsm @@ -0,0 +1,505 @@ +/* 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 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. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderSearchTips"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm", + DefaultBrowserCheck: "resource:///modules/BrowserGlue.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + ProfileAge: "resource://gre/modules/ProfileAge.jsm", + Services: "resource://gre/modules/Services.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "updateManager", () => { + return ( + Cc["@mozilla.org/updates/update-manager;1"] && + Cc["@mozilla.org/updates/update-manager;1"].getService(Ci.nsIUpdateManager) + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "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", + 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; + +// 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 && 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. + */ + get TIP_TYPE() { + return TIPS; + } + + get PRIORITY() { + // Search tips are prioritized over the UnifiedComplete and top sites + // providers. + return UrlbarProviderTopSites.PRIORITY + 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. + */ + get name() { + return "UrlbarProviderSearchTips"; + } + + /** + * The type of the provider. + */ + 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 && 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. + * @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. + */ + 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(); + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: tip, + buttonTextData: { id: "urlbar-search-tips-confirm" }, + icon: defaultEngine.iconURI?.spec, + } + ); + + switch (tip) { + case TIPS.ONBOARD: + result.heuristic = true; + result.payload.textData = { + id: "urlbar-search-tips-onboard", + args: { + engineName: defaultEngine.name, + }, + }; + break; + case TIPS.REDIRECT: + result.heuristic = false; + result.payload.textData = { + id: "urlbar-search-tips-redirect-2", + args: { + engineName: defaultEngine.name, + }, + }; + break; + } + + if (instance != this.queryInstance) { + return; + } + + 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. + */ + pickResult(result) { + let window = BrowserWindowTracker.getTopWindow(); + window.gURLBar.value = ""; + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.focus(); + } + + /** + * 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: start, + * engagement, abandonment, discard. + */ + onEngagement(isPrivate, state) { + if ( + this.showedTipTypeInCurrentEngagement != TIPS.NONE && + state == "engagement" + ) { + // The user either clicked the tip's "Okay, Got It" button, or they + // engaged with 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. + UrlbarPrefs.set( + `tipShownCount.${this.showedTipTypeInCurrentEngagement}`, + MAX_SHOWN_COUNT + ); + } + this.showedTipTypeInCurrentEngagement = TIPS.NONE; + } + + /** + * Called from `onLocationChange` in browser.js. + * @param {window} window + * The browser window where the location change happened. + * @param {URL} uri + * The URI being navigated to. + * @param {nsIWebProgress} webProgress + * @param {number} flags + * Load flags. See nsIWebProgressListener.idl for possible values. + */ + async onLocationChange(window, uri, 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.jsm to avoid the + // perf impact of loading Timer.jsm, 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 ( + !cfrFeaturesUserPref || + (this.disableTipsForCurrentSession && + !UrlbarPrefs.get("searchTips.test.ignoreShowLimits")) + ) { + return; + } + + this._maybeShowTipForUrl(uri.spec).catch(Cu.reportError); + } + + /** + * Determines whether we should show a tip for the current tab, sets + * this.currentTip, and starts a search on an empty string. + * @param {number} urlStr + * The URL of the page being loaded, in string form. + */ + async _maybeShowTipForUrl(urlStr) { + let instance = {}; + this._maybeShowTipForUrlInstance = instance; + + // 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)); + if (isNewtab) { + tip = TIPS.ONBOARD; + } else if (isSearchHomepage) { + tip = TIPS.REDIRECT; + } else { + // No tip. + return; + } + + let ignoreShowLimits = UrlbarPrefs.get("searchTips.test.ignoreShowLimits"); + + // If we've shown this type of tip the maximum number of times over all + // sessions, don't show it again. + let shownCount = UrlbarPrefs.get(`tipShownCount.${tip}`); + if (shownCount >= MAX_SHOWN_COUNT && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser is already showing some other + // notification. + if ((await isBrowserShowingNotification()) && !ignoreShowLimits) { + return; + } + + // Don't show a tip if the browser has been updated recently. + let date = await lastBrowserUpdateDate(); + if (Date.now() - date <= LAST_UPDATE_THRESHOLD_MS && !ignoreShowLimits) { + return; + } + + // At this point, we're showing a tip. + this.disableTipsForCurrentSession = true; + + // Store the new shown count. + UrlbarPrefs.set(`tipShownCount.${tip}`, shownCount + 1); + + // Start a search. + setTimeout(() => { + if (this._maybeShowTipForUrlInstance != instance) { + return; + } + + let window = BrowserWindowTracker.getTopWindow(); + // We don't want to interrupt a user's typed query with a Search Tip. + // See bugs 1613662 and 1619547. + if ( + window.gURLBar.getAttribute("pageproxystate") == "invalid" && + window.gURLBar.value != "" + ) { + return; + } + + this.currentTip = tip; + window.gURLBar.search("", { focus: tip == TIPS.ONBOARD }); + }, SHOW_TIP_DELAY_MS); + } +} + +async function isBrowserShowingNotification() { + let window = BrowserWindowTracker.getTopWindow(); + + // urlbar view and notification box (info bar) + if ( + window.gURLBar.view.isOpen || + window.gHighPriorityNotificationBox.currentNotification || + window.gBrowser.getNotificationBox().currentNotification + ) { + return true; + } + + // app menu notification doorhanger + if ( + AppMenuNotifications.activeNotification && + !AppMenuNotifications.activeNotification.dismissed && + !AppMenuNotifications.activeNotification.options.badgeOnly + ) { + return true; + } + + // tracking protection and identity box doorhangers + if ( + ["tracking-protection-icon-container", "identity-box"].some( + id => window.document.getElementById(id).getAttribute("open") == "true" + ) + ) { + 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; + } + } + + // 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 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 (updateManager && updateManager.getUpdateCount()) { + let update = updateManager.getUpdateAt(0); + return update.installDate; + } + // Fall back to the profile age. + let age = await ProfileAge(); + return (await age.firstUse) || age.created; +} + +var UrlbarProviderSearchTips = new ProviderSearchTips(); diff --git a/browser/components/urlbar/UrlbarProviderTabToSearch.jsm b/browser/components/urlbar/UrlbarProviderTabToSearch.jsm new file mode 100644 index 0000000000..d7f2c49f22 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTabToSearch.jsm @@ -0,0 +1,435 @@ +/* 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 module exports a provider that offers a search engine when the user is + * typing a search engine domain. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderTabToSearch"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + UrlbarView: "resource:///modules/UrlbarView.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +const DYNAMIC_RESULT_TYPE = "onboardTabToSearch"; +const VIEW_TEMPLATE = { + attributes: { + role: "group", + 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() { + UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE); + UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE); +} + +/** + * Class used to create the provider. + */ +class ProviderTabToSearch extends UrlbarProvider { + constructor() { + super(); + this.onboardingEnginesShown = 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 && + 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: UrlbarUtils.WEB_ENGINE_NAMES.has(result.payload.engine) + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + args: { + engine: result.payload.engine, + }, + }, + }, + description: { + l10n: { + id: "urlbar-tabtosearch-onboard", + }, + }, + }; + } + + /** + * Called when any selectable element in a dynamic result's view is picked. + * + * @param {UrlbarResult} result + * The result that was picked. + * @param {Element} element + * The element in the result's view that was picked. + */ + pickResult(result, element) { + element.ownerGlobal.gURLBar.maybeConfirmSearchModeFromResult({ + result, + checkValue: false, + }); + } + + /** + * 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 pickResult. + * + * @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 = UrlbarPrefs.get( + "tabToSearch.onboard.interactionsLeft" + ); + + if (interactionsLeft > 0) { + UrlbarPrefs.set( + "tabToSearch.onboard.interactionsLeft", + --interactionsLeft + ); + } + + this.onboardingInteractionAtTime = Date.now(); + } + } + + /** + * Called when the user starts and ends an engagement with the urlbar. We + * clear onboardingEnginesShown on engagement because we want to record in + * urlbar.tips once per engagement per engine. This has the unfortunate side + * effect of recording again when the user re-opens a view with a retained + * tab-to-search result. This is an acceptable tradeoff for not recording + * multiple times if the user backspaces autofill but then retypes the engine + * hostname, yielding the same tab-to-search result. + * + * @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 (!this.onboardingEnginesShown.size) { + return; + } + + Services.telemetry.keyedScalarAdd( + "urlbar.tips", + "tabtosearch_onboard-shown", + this.onboardingEnginesShown.size + ); + this.onboardingEnginesShown.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 ( + !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 UrlbarSearchUtils.enginesForDomainPrefix(searchStr, { + matchAllDomainLevels: true, + onlyEnabled: true, + }); + if (!engines.length) { + return; + } + + const onboardingInteractionsLeft = 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.getResultDomain(), { + 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.getResultDomain(), 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 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.getResultDomain(), { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.DYNAMIC, + UrlbarUtils.RESULT_SOURCE.SEARCH, + { + engine: engine.name, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + dynamicType: DYNAMIC_RESULT_TYPE, + satisfiesAutofillThreshold, + } + ); + result.resultSpan = 2; + result.suggestedIndex = 1; + return result; +} + +function makeResult(context, engine, satisfiesAutofillThreshold = false) { + let [url] = UrlbarUtils.stripPrefixAndTrim(engine.getResultDomain(), { + stripWww: true, + }); + url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length); + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights(context.tokens, { + engine: engine.name, + url, + providesSearchMode: true, + icon: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + query: "", + satisfiesAutofillThreshold, + }) + ); + result.suggestedIndex = 1; + return result; +} + +var UrlbarProviderTabToSearch = new ProviderTabToSearch(); +initializeDynamicResult(); diff --git a/browser/components/urlbar/UrlbarProviderTokenAliasEngines.jsm b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.jsm new file mode 100644 index 0000000000..42e3adeb86 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTokenAliasEngines.jsm @@ -0,0 +1,228 @@ +/* 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 module exports a provider that offers token alias engines. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderTokenAliasEngines"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * 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 UnifiedComplete. + 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 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 (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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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. UrlbarProviderUnifiedComplete will take + // over at this point. + if ( + lowerCaseSearchString.startsWith(alias) && + 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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; + } +} + +var UrlbarProviderTokenAliasEngines = new ProviderTokenAliasEngines(); diff --git a/browser/components/urlbar/UrlbarProviderTopSites.jsm b/browser/components/urlbar/UrlbarProviderTopSites.jsm new file mode 100644 index 0000000000..37d3e3c873 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderTopSites.jsm @@ -0,0 +1,249 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarProviderTopSites"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", + TOP_SITES_MAX_SITES_PER_ROW: "resource://activity-stream/common/Reducers.jsm", + TOP_SITES_DEFAULT_ROWS: "resource://activity-stream/common/Reducers.jsm", +}); + +/** + * This module exports a provider returning the user's newtab Top Sites. + */ + +/** + * A provider that returns the Top Sites shown on about:newtab. + */ +class ProviderTopSites extends UrlbarProvider { + constructor() { + super(); + } + + get PRIORITY() { + // Top sites are prioritized over the UnifiedComplete 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. + */ + get name() { + return "UrlbarProviderTopSites"; + } + + /** + * The type of the provider. + */ + 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. + * @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. + */ + async startQuery(queryContext, addCallback) { + // If system.topsites is disabled, we would get stale or empty Top Sites + // data. 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 UnifiedComplete'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. + if ( + !UrlbarPrefs.get("suggest.topsites") || + !Services.prefs.getBoolPref( + "browser.newtabpage.activity-stream.feeds.system.topsites", + false + ) + ) { + return; + } + + let sites = 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); + + // 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", + 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( + UrlbarPrefs.get("maxRichResults"), + TOP_SITES_MAX_SITES_PER_ROW * this.topSitesRows + ); + sites = sites.slice(0, numTopSites); + + sites = sites.map(link => ({ + 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, + })); + + for (let site of sites) { + switch (site.type) { + case "url": { + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: site.title, + url: site.url, + icon: site.favicon, + isPinned: site.isPinned, + isSponsored: site.isSponsored, + sendAttributionRequest: site.sendAttributionRequest, + }) + ); + + let allowTabSwitch = + !queryContext.isPrivate || + PrivateBrowsingUtils.permanentPrivateBrowsing; + + let tabs; + if (allowTabSwitch && UrlbarPrefs.get("suggest.openpage")) { + tabs = UrlbarProviderOpenTabs.openTabs.get( + queryContext.userContextId || 0 + ); + } + + if (tabs && tabs.includes(site.url.replace(/#.*$/, ""))) { + result.type = UrlbarUtils.RESULT_TYPE.TAB_SWITCH; + result.source = UrlbarUtils.RESULT_SOURCE.TABS; + } else if (UrlbarPrefs.get("suggest.bookmark")) { + let bookmark = await 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 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 UrlbarSearchUtils.enginesForDomainPrefix(host) + )[0]; + } + } + + if (!engine) { + // No engine found. We skip this Top Site. + break; + } + + if (instance != this.queryInstance) { + break; + } + + let result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...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: + Cu.reportError(`Unknown Top Site type: ${site.type}`); + break; + } + } + } +} + +var UrlbarProviderTopSites = new ProviderTopSites(); diff --git a/browser/components/urlbar/UrlbarProviderUnifiedComplete.jsm b/browser/components/urlbar/UrlbarProviderUnifiedComplete.jsm new file mode 100644 index 0000000000..006105d303 --- /dev/null +++ b/browser/components/urlbar/UrlbarProviderUnifiedComplete.jsm @@ -0,0 +1,359 @@ +/* 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 module exports a provider that wraps the existing UnifiedComplete + * component, it is supposed to be used as an interim solution while we rewrite + * the model providers in a more modular way. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProviderUnifiedComplete"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "unifiedComplete", + "@mozilla.org/autocomplete/search;1?name=unifiedcomplete", + "nsIAutoCompleteSearch" +); + +/** + * Class used to create the provider. + */ +class ProviderUnifiedComplete extends UrlbarProvider { + constructor() { + super(); + } + + /** + * Returns the name of this provider. + * @returns {string} the name of this provider. + */ + get name() { + return "UnifiedComplete"; + } + + /** + * 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) { + if ( + !queryContext.trimmedSearchString && + queryContext.searchMode?.engineName && + 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. + */ + async startQuery(queryContext, addCallback) { + let instance = this.queryInstance; + let urls = new Set(); + await unifiedComplete.wrappedJSObject.startQuery(queryContext, acResult => { + if (instance != this.queryInstance) { + return; + } + let results = convertLegacyAutocompleteResult( + queryContext, + acResult, + urls + ); + for (let result of results) { + addCallback(this, result); + } + }); + } + + /** + * Cancels a running query. + * @param {object} queryContext The query context object + */ + cancelQuery(queryContext) { + unifiedComplete.stopSearch(); + } +} + +var UrlbarProviderUnifiedComplete = new ProviderUnifiedComplete(); + +/** + * Convert from a nsIAutocompleteResult to a list of results. + * 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. + * In any case at least we're sure there's just one heuristic result and it + * comes first. + * + * @param {UrlbarQueryContext} context the query context. + * @param {object} acResult an nsIAutocompleteResult + * @param {set} urls a Set containing all the found urls, used to discard + * already added results. + * @returns {array} converted results + */ +function convertLegacyAutocompleteResult(context, acResult, urls) { + let results = []; + for (let i = 0; i < acResult.matchCount; ++i) { + // First, let's check if we already added this result. + // nsIAutocompleteResult 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 = acResult.getFinalCompleteValueAt(i); + if (urls.has(url)) { + continue; + } + urls.add(url); + let style = acResult.getStyleAt(i); + let isHeuristic = i == 0 && style.includes("heuristic"); + let result = makeUrlbarResult(context.tokens, { + url, + // getImageAt returns 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: acResult.getImageAt(i) || undefined, + style, + comment: acResult.getCommentAt(i), + firstToken: context.tokens[0], + isHeuristic, + }); + // Should not happen, but better safe than sorry. + if (!result) { + continue; + } + // Manage autofill for the first result. + if ( + isHeuristic && + style.includes("autofill") && + acResult.defaultIndex == 0 + ) { + let autofillValue = acResult.getValueAt(i); + if ( + autofillValue + .toLocaleLowerCase() + .startsWith(context.searchString.toLocaleLowerCase()) + ) { + result.autofill = { + value: + context.searchString + + autofillValue.substring(context.searchString.length), + selectionStart: context.searchString.length, + selectionEnd: autofillValue.length, + }; + } + } + result.heuristic = isHeuristic; + 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 = PlacesUtils.parseActionUrl(info.url); + if (action) { + switch (action.type) { + case "searchengine": { + if (action.params.isSearchHistory) { + // Return a form history result. + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.HISTORY, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + engine: action.params.engineName, + suggestion: [ + action.params.searchSuggestion, + UrlbarUtils.HIGHLIGHT.SUGGESTED, + ], + lowerCaseSuggestion: action.params.searchSuggestion.toLocaleLowerCase(), + }) + ); + } + + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + engine: [action.params.engineName, UrlbarUtils.HIGHLIGHT.TYPED], + suggestion: [ + action.params.searchSuggestion, + UrlbarUtils.HIGHLIGHT.SUGGESTED, + ], + lowerCaseSuggestion: action.params.searchSuggestion?.toLocaleLowerCase(), + keyword: action.params.alias, + query: [ + action.params.searchQuery.trim(), + UrlbarUtils.HIGHLIGHT.NONE, + ], + icon: info.icon, + }) + ); + } + case "keyword": { + let title = info.comment; + if (!title) { + // If the url doesn't have an host (e.g. javascript urls), comment + // will be empty, and we can't build the usual title. Thus use the url. + title = Services.textToSubURI.unEscapeURIForUI(action.params.url); + } else if (tokens && tokens.length > 1) { + title = UrlbarUtils.strings.formatStringFromName( + "bookmarkKeywordSearch", + [ + info.comment, + tokens + .slice(1) + .map(t => t.value) + .join(" "), + ] + ); + } + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.KEYWORD, + UrlbarUtils.RESULT_SOURCE.BOOKMARKS, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + title: [title, UrlbarUtils.HIGHLIGHT.TYPED], + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [info.firstToken.value, UrlbarUtils.HIGHLIGHT.TYPED], + input: [action.params.input], + postData: [action.params.postData], + icon: info.icon, + }) + ); + } + case "remotetab": + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.REMOTE_TAB, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + device: [action.params.deviceName, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + case "switchtab": + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + case "visiturl": + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + title: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + url: [action.params.url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + default: + Cu.reportError(`Unexpected action type: ${action.type}`); + return null; + } + } + + if (info.style.includes("priority-search")) { + return new UrlbarResult( + UrlbarUtils.RESULT_TYPE.SEARCH, + UrlbarUtils.RESULT_SOURCE.SEARCH, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + engine: [info.comment, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + }) + ); + } + + // This is a normal url/title tuple. + let source; + let tags = []; + let comment = info.comment; + + // UnifiedComplete 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 if (info.style.includes("preloaded-top-sites")) { + source = UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL; + } 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 UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + source, + ...UrlbarResult.payloadAndSimpleHighlights(tokens, { + url: [info.url, UrlbarUtils.HIGHLIGHT.TYPED], + icon: info.icon, + title: [comment, UrlbarUtils.HIGHLIGHT.TYPED], + tags: [tags, UrlbarUtils.HIGHLIGHT.TYPED], + }) + ); +} diff --git a/browser/components/urlbar/UrlbarProvidersManager.jsm b/browser/components/urlbar/UrlbarProvidersManager.jsm new file mode 100644 index 0000000000..e848666566 --- /dev/null +++ b/browser/components/urlbar/UrlbarProvidersManager.jsm @@ -0,0 +1,714 @@ +/* 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 module exports a component used to register search providers and manage + * the connection between such providers and a UrlbarController. + */ + +var EXPORTED_SYMBOLS = ["UrlbarProvidersManager"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + SkippableTimer: "resource:///modules/UrlbarUtils.jsm", + UrlbarMuxer: "resource:///modules/UrlbarUtils.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => + 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. +var localProviderModules = { + UrlbarProviderUnifiedComplete: + "resource:///modules/UrlbarProviderUnifiedComplete.jsm", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.jsm", + UrlbarProviderHeuristicFallback: + "resource:///modules/UrlbarProviderHeuristicFallback.jsm", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.jsm", + UrlbarProviderOmnibox: "resource:///modules/UrlbarProviderOmnibox.jsm", + UrlbarProviderPrivateSearch: + "resource:///modules/UrlbarProviderPrivateSearch.jsm", + UrlbarProviderSearchTips: "resource:///modules/UrlbarProviderSearchTips.jsm", + UrlbarProviderSearchSuggestions: + "resource:///modules/UrlbarProviderSearchSuggestions.jsm", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.jsm", + UrlbarProviderTokenAliasEngines: + "resource:///modules/UrlbarProviderTokenAliasEngines.jsm", + UrlbarProviderTopSites: "resource:///modules/UrlbarProviderTopSites.jsm", +}; + +// List of available local muxers, each is implemented in its own jsm module. +var localMuxerModules = { + UrlbarMuxerUnifiedComplete: + "resource:///modules/UrlbarMuxerUnifiedComplete.jsm", +}; + +// 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.import(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.import(module, {}); + this.registerMuxer(muxer); + } + } + + /** + * Registers a provider object with the manager. + * @param {object} provider + */ + registerProvider(provider) { + if (!provider || !(provider instanceof UrlbarProvider)) { + throw new Error(`Trying to register an invalid provider`); + } + if (!Object.values(UrlbarUtils.PROVIDER_TYPE).includes(provider.type)) { + throw new Error(`Unknown provider type ${provider.type}`); + } + logger.info(`Registering provider ${provider.name}`); + let index = -1; + if (provider.type == 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 != 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 + */ + unregisterProvider(provider) { + 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 UrlbarMuxer)) { + throw new Error(`Trying to register an invalid muxer`); + } + 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; + 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) { + logger.info(`Query start ${queryContext.searchString}`); + + // Define the muxer to use. + let muxerName = queryContext.muxer || DEFAULT_MUXER; + logger.info(`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. + 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 (UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(restrictToken.value)) { + queryContext.restrictSource = queryContext.sources[0]; + } + } + 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. + await UrlbarSearchUtils.init(); + if (query.canceled) { + return; + } + + // Update the behavior of extension providers. + let updateBehaviorPromises = []; + for (let provider of this.providers) { + if ( + provider.type == 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 + */ + cancelQuery(queryContext) { + 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 = 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. + * + * @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. + */ + notifyEngagementChange(isPrivate, state) { + for (let provider of this.providers) { + provider.tryMethod("onEngagement", isPrivate, state); + } + } +} + +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.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(Cu.reportError) + ); + } + + // 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.info(`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 == 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 SkippableTimer({ + name: "Query provider timer", + time: UrlbarPrefs.get("delay"), + logger: provider.logger, + }); + } + queryPromises.push( + this._sleepTimer.promise.then(() => + this.canceled ? undefined : startQuery(provider) + ) + ); + } + + logger.info(`Queried ${queryPromises.length} providers`); + 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.info( + `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(Cu.reportError); + } + if (this._chunkTimer) { + this._chunkTimer.cancel().catch(Cu.reportError); + } + if (this._sleepTimer) { + this._sleepTimer.fire().catch(Cu.reportError); + } + } + + /** + * Adds a result returned from a provider to the results set. + * @param {object} provider + * @param {object} result + */ + add(provider, result) { + if (!(provider instanceof 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 != UrlbarUtils.RESULT_TYPE.SEARCH || + result.source != UrlbarUtils.RESULT_SOURCE.HISTORY || + !this.acceptableSources.includes(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 != UrlbarUtils.RESULT_TYPE.KEYWORD && + result.payload.url && + result.payload.url.startsWith("javascript:") && + !this.context.searchString.startsWith("javascript:") && + UrlbarPrefs.get("filter.javascript") + ) { + return; + } + + result.providerName = provider.name; + result.providerType = provider.type; + this.context.results.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 == UrlbarUtils.PROVIDER_TYPE.HEURISTIC) { + if (!this._heuristicProviderTimer) { + this._heuristicProviderTimer = new SkippableTimer({ + name: "Heuristic provider timer", + callback: () => this._notifyResults(), + time: CHUNK_RESULTS_DELAY_MS, + logger: provider.logger, + }); + } + } else if (!this._chunkTimer) { + this._chunkTimer = new SkippableTimer({ + name: "Query chunk timer", + callback: () => this._notifyResults(), + time: CHUNK_RESULTS_DELAY_MS, + 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(Cu.reportError); + } + } + + _notifyResults() { + this.muxer.sort(this.context); + + if (this._heuristicProviderTimer) { + this._heuristicProviderTimer.cancel().catch(Cu.reportError); + this._heuristicProviderTimer = null; + } + + if (this._chunkTimer) { + this._chunkTimer.cancel().catch(Cu.reportError); + this._chunkTimer = null; + } + + // Before the muxer.sort call above, this.context.results should never be + // empty since this method is called when results are added. But the muxer + // may have excluded one or more results from the final sorted list. For + // example, it excludes the "Search in a Private Window" result if it's the + // only result that's been added so far. 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; + } + + // Crop results to the requested number, taking their result spans into + // account. + let resultCount = this.context.maxResults; + for (let i = 0; i < this.context.results.length; i++) { + resultCount -= UrlbarUtils.getSpanForResult(this.context.results[i]); + if (resultCount < 0) { + logger.debug( + `Splicing results from ${i} to crop results to ${this.context.maxResults}` + ); + this.context.results.splice(i, this.context.results.length - i); + break; + } + } + + this.context.firstResultChanged = !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 about sources. + let restrictToken = context.tokens.find(t => + [ + UrlbarTokenizer.TYPE.RESTRICT_HISTORY, + UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK, + UrlbarTokenizer.TYPE.RESTRICT_TAG, + UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE, + UrlbarTokenizer.TYPE.RESTRICT_SEARCH, + ].includes(t.type) + ); + let restrictTokenType = restrictToken ? restrictToken.type : undefined; + for (let source of Object.values(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 UrlbarUtils.RESULT_SOURCE.BOOKMARKS: + if ( + restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_BOOKMARK || + restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_TAG || + (!restrictTokenType && UrlbarPrefs.get("suggest.bookmark")) + ) { + acceptedSources.push(source); + } + break; + case UrlbarUtils.RESULT_SOURCE.HISTORY: + if ( + restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_HISTORY || + (!restrictTokenType && UrlbarPrefs.get("suggest.history")) + ) { + acceptedSources.push(source); + } + break; + case UrlbarUtils.RESULT_SOURCE.SEARCH: + if ( + restrictTokenType === 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 UrlbarUtils.RESULT_SOURCE.TABS: + if ( + restrictTokenType === UrlbarTokenizer.TYPE.RESTRICT_OPENPAGE || + (!restrictTokenType && UrlbarPrefs.get("suggest.openpage")) + ) { + acceptedSources.push(source); + } + break; + case UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK: + if (!context.isPrivate && !restrictTokenType) { + acceptedSources.push(source); + } + break; + case UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL: + default: + if (!restrictTokenType) { + acceptedSources.push(source); + } + break; + } + } + context.sources = acceptedSources; + return restrictToken; +} diff --git a/browser/components/urlbar/UrlbarResult.jsm b/browser/components/urlbar/UrlbarResult.jsm new file mode 100644 index 0000000000..fe333f7442 --- /dev/null +++ b/browser/components/urlbar/UrlbarResult.jsm @@ -0,0 +1,339 @@ +/* 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 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. + */ + +var EXPORTED_SYMBOLS = ["UrlbarResult"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * Class used to create a single result. + */ +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 correspoding + * payload property. + */ + constructor(resultType, resultSource, payload, payloadHighlights = {}) { + // Type describes the payload and visualization that should be used for + // this result. + if (!Object.values(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(UrlbarUtils.RESULT_SOURCE).includes(resultSource)) { + throw new Error("Invalid result source"); + } + this.source = resultSource; + + // UrlbarView is responsible for updating this. + this.rowIndex = -1; + + // This is an optional hint to the Muxer that can be set by a provider to + // suggest a specific position among the results. + this.suggestedIndex = -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; + + // 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 UrlbarUtils.RESULT_TYPE.KEYWORD: + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + case UrlbarUtils.RESULT_TYPE.URL: + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + return this.payload.title + ? [this.payload.title, this.payloadHighlights.title] + : [this.payload.url || "", this.payloadHighlights.url || []]; + case 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 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 = UrlbarUtils.getPayloadSchema(this.type); + if (!schema) { + throw new Error(`Unrecognized result type: ${this.type}`); + } + let result = JsonSchemaValidator.validate(payload, schema, { + allowExplicitUndefinedProperties: true, + allowNullAsUndefinedProperties: true, + allowExtraProperties: this.type == 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.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 || [ + "", + 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 && UrlbarPrefs.get("trimURLs")) { + url = BrowserUtils.removeSingleTrailingSlashFromURL(url); + if (url.startsWith("https://")) { + url = url.substring(8); + if (url.startsWith("www.")) { + url = url.substring(4); + } + } + } + payloadInfo.displayUrl[0] = Services.textToSubURI.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, 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) + ? UrlbarUtils.getTokenMatches(tokens, val || "", highlightType) + : val.map(subval => + 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)) { + Cu.reportError(`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.jsm b/browser/components/urlbar/UrlbarSearchOneOffs.jsm new file mode 100644 index 0000000000..508e87f564 --- /dev/null +++ b/browser/components/urlbar/UrlbarSearchOneOffs.jsm @@ -0,0 +1,374 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarSearchOneOffs"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + SearchOneOffs: "resource:///modules/SearchOneOffs.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * The one-off search buttons in the urlbar. + */ +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; + UrlbarPrefs.addObserver(this); + // Override the SearchOneOffs.jsm value for the Address Bar. + this.disableOneOffsHorizontalKeyNavigation = true; + } + + /** + * 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); + } + + /** + * 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.settingsButtonCompact + ) { + 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; + } + + /** + * @returns {number} + * The selected index in the view. + */ + get selectedViewIndex() { + return this.view.selectedElementIndex; + } + + /** + * Sets the selected index in the view. + * + * @param {number} val + * The selected index or -1 if no selection. + */ + set selectedViewIndex(val) { + this.view.selectedElementIndex = 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 is a special case. Its action should be executed + // immediately. + if ( + this.selectedButton == this.view.oneOffSearchButtons.settingsButtonCompact + ) { + this.input.controller.engagementEvent.discard(); + this.selectedButton.doCommand(); + return; + } + + // We allow autofill in local but not remote search modes. + let startQueryParams = { + allowAutofill: + !searchMode.engineName && + searchMode.source != 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 (UrlbarUtils.LOCAL_SEARCH_MODES.some(m => 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 ( + [...UrlbarUtils.LOCAL_SEARCH_MODES.map(m => m.pref)].includes(changedPref) + ) { + this.invalidateCache(); + } + } + + /** + * Overrides _rebuildEngineList to add the local one-offs. + * + * @param {array} engines + * The search engines to add. + */ + _rebuildEngineList(engines) { + super._rebuildEngineList(engines); + + for (let { source, pref, restrict } of UrlbarUtils.LOCAL_SEARCH_MODES) { + if (!UrlbarPrefs.get(pref)) { + continue; + } + let name = 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"); + 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.jsm b/browser/components/urlbar/UrlbarSearchUtils.jsm new file mode 100644 index 0000000000..0a2a1d30e2 --- /dev/null +++ b/browser/components/urlbar/UrlbarSearchUtils.jsm @@ -0,0 +1,293 @@ +/* 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. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["UrlbarSearchUtils"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +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] + * @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 } = {} + ) { + await this.init(); + 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 fully matched engines. + let engines = []; + for (let engine of await Services.search.getVisibleEngines()) { + if (disabledEngines.includes(engine.name)) { + continue; + } + let domain = engine.getResultDomain(); + if (domain.startsWith(prefix) || domain.startsWith("www." + prefix)) { + engines.push(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) + ); + } + } + + // Partial matches come after perfect matches. + return [...engines, ...partialMatchEngines]; + } + + /** + * Gets the engine with a given alias. + * + * @param {string} alias + * A search engine alias. The alias string comparison is case insensitive. + * @returns {nsISearchEngine} + * The matching engine or null if there isn't one. + */ + async engineForAlias(alias) { + await Promise.all([this.init(), this._refreshEnginesByAliasPromise]); + return this._enginesByAlias.get(alias.toLocaleLowerCase()) || null; + } + + /** + * The list of engines with token ("@") aliases. + * + * @returns {array} + * Array of objects { engine, tokenAliases } for token alias engines. + */ + async tokenAliasEngines() { + await this.init(); + 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 + * @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.getResultDomain(); + 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(); + } + + getDefaultEngine(isPrivate = false) { + return this.separatePrivateDefaultUIEnabled && + this.separatePrivateDefault && + isPrivate + ? Services.search.defaultPrivateEngine + : Services.search.defaultEngine; + } + + 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); + } + } + } + } + + /** + * 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. + * + * @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`. + * + * @note 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. + */ + 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; + }, []); + } + + observe(subject, topic, data) { + switch (data) { + case "engine-added": + case "engine-changed": + case "engine-removed": + case "engine-default": + this._refreshEnginesByAliasPromise = this._refreshEnginesByAlias(); + break; + } + } +} + +var UrlbarSearchUtils = new SearchUtils(); diff --git a/browser/components/urlbar/UrlbarTokenizer.jsm b/browser/components/urlbar/UrlbarTokenizer.jsm new file mode 100644 index 0000000000..4991f87fac --- /dev/null +++ b/browser/components/urlbar/UrlbarTokenizer.jsm @@ -0,0 +1,407 @@ +/* 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 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. + */ + +var EXPORTED_SYMBOLS = ["UrlbarTokenizer"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logger", () => + UrlbarUtils.getLogger({ prefix: "Tokenizer" }) +); + +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, + + 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, + }, + + // 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: "$", + }, + + // 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, + ]); + }, + + /** + * 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) : ""; + 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 {boolean} [ignoreKnownDomains] If true, the origin doesn't have to be + * in the known domain list + * @param {boolean} [noIp] If true, the origin cannot be an IP address + * @param {boolean} [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); + logger.debug("userinfo", userinfo); + 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) { + logger.info("Tokenizing", queryContext); + 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 {string} token + * @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); + let accumulator = []; + let hasRestrictionToken = tokens.some(t => CHAR_TO_TYPE_MAP.has(t)); + let chars = Array.from(CHAR_TO_TYPE_MAP.keys()).join(""); + logger.debug("Restriction chars", chars); + for (let i = 0; i < tokens.length; ++i) { + // 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. + let token = tokens[i]; + if (!hasRestrictionToken && token.length > 1) { + // 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. + if ( + i == 0 && + chars.includes(token[0]) && + !UrlbarTokenizer.REGEXP_PERCENT_ENCODED_START.test(token) + ) { + hasRestrictionToken = true; + accumulator.push(token[0]); + accumulator.push(token.slice(1)); + continue; + } else if ( + i == tokens.length - 1 && + token[token.length - 1] == UrlbarTokenizer.RESTRICT.SEARCH && + !UrlbarTokenizer.looksLikeUrl(token, { requirePath: true }) + ) { + hasRestrictionToken = true; + accumulator.push(token.slice(0, token.length - 1)); + accumulator.push(token[token.length - 1]); + continue; + } + } + accumulator.push(token); + } + logger.info("Found tokens", accumulator); + return accumulator; +} + +/** + * 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)); + } + } + + logger.info("Filtered Tokens", tokens); + return filtered; +} diff --git a/browser/components/urlbar/UrlbarUtils.jsm b/browser/components/urlbar/UrlbarUtils.jsm new file mode 100644 index 0000000000..b8fd02a5fe --- /dev/null +++ b/browser/components/urlbar/UrlbarUtils.jsm @@ -0,0 +1,1758 @@ +/* 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 module exports the UrlbarUtils singleton, which contains constants and + * helper functions that are useful to all components of the urlbar. + */ + +var EXPORTED_SYMBOLS = [ + "UrlbarMuxer", + "UrlbarProvider", + "UrlbarQueryContext", + "UrlbarUtils", + "SkippableTimer", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + FormHistory: "resource://gre/modules/FormHistory.jsm", + Log: "resource://gre/modules/Log.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", +}); + +var UrlbarUtils = { + // Extensions are allowed to add suggestions if they have registered a keyword + // with the omnibox API. This is the maximum number of suggestions an extension + // is allowed to add for a given search string. + // This value includes the heuristic result. + MAXIMUM_ALLOWED_EXTENSION_MATCHES: 6, + + // This is used by UnifiedComplete, the new implementation will use + // PROVIDER_TYPE and RESULT_TYPE + RESULT_GROUP: { + HEURISTIC: "heuristic", + GENERAL: "general", + SUGGESTION: "suggestion", + EXTENSION: "extension", + }, + + // 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, + }, + + /** + * Buckets used for logging telemetry to the FX_URLBAR_SELECTED_RESULT_TYPE_2 + * histogram. + */ + SELECTED_RESULT_TYPES: { + autofill: 0, + bookmark: 1, + history: 2, + keyword: 3, + searchengine: 4, + searchsuggestion: 5, + switchtab: 6, + tag: 7, + visiturl: 8, + remotetab: 9, + extension: 10, + "preloaded-top-site": 11, // This is currently unused. + tip: 12, + topsite: 13, + formhistory: 14, + dynamic: 15, + tabtosearch: 16, + // n_values = 32, so you'll need to create a new histogram if you need more. + }, + + // 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://browser/content/extension.svg", + HISTORY: "chrome://browser/skin/history.svg", + SEARCH_GLASS: "chrome://browser/skin/search-glass.svg", + SEARCH_GLASS_INVERTED: "chrome://browser/skin/search-glass-inverted.svg", + TIP: "chrome://browser/skin/tip.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, + }, + + // UnifiedComplete's autocomplete results store their titles and tags together + // in their comments. This separator is used to separate them. When we + // rewrite UnifiedComplete for quantumbar, 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+)?$/, + + // Names of engines shipped in Firefox that search the web in general. These + // are used to update the input placeholder when entering search mode. + // TODO (Bug 1658661): Don't hardcode this list; store search engine category + // information someplace better. + WEB_ENGINE_NAMES: new Set([ + "百度", // Baidu + "百度搜索", // "Baidu Search", the name of Baidu's OpenSearch engine. + "Bing", + "DuckDuckGo", + "Ecosia", + "Google", + "Qwant", + "Yandex", + "Яндекс", // Yandex, non-EN + ]), + + // 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", + "other", + "shortcut", + "tabmenu", + "tabtosearch", + "tabtosearch_onboard", + "topsites_newtab", + "topsites_urlbar", + "touchbar", + "typed", + ]), + + // 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: UrlbarTokenizer.RESTRICT.BOOKMARK, + icon: "chrome://browser/skin/bookmark.svg", + pref: "shortcuts.bookmarks", + }, + { + source: UrlbarUtils.RESULT_SOURCE.TABS, + restrict: UrlbarTokenizer.RESTRICT.OPENPAGE, + icon: "chrome://browser/skin/tab.svg", + pref: "shortcuts.tabs", + }, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + restrict: UrlbarTokenizer.RESTRICT.HISTORY, + icon: "chrome://browser/skin/history.svg", + pref: "shortcuts.history", + }, + ]; + }, + + /** + * 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 ( + !PrivateBrowsingUtils.isWindowPrivate(window) && + url && + !url.includes(" ") && + // eslint-disable-next-line no-control-regex + !/[\x00-\x1F]/.test(url) + ) { + 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} + * @resolves { 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 PlacesUtils.keywords.fetch(keyword); + } catch (ex) { + Cu.reportError(`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 BrowserUtils.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 { lowerCaseValue: needle } of tokens) { + // 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++; + } + } + } + } + // 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; + }, + + /** + * 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; + } + case UrlbarUtils.RESULT_TYPE.TIP: { + // Return the button URL. Consumers must check payload.helpUrl + // themselves if they need the tip's help link. + return { url: result.payload.buttonUrl, postData: null }; + } + } + 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; + } + 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; + }, + + /** + * 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 == UrlbarTokenizer.RESTRICT.SEARCH) { + return { + engineName: 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. + * @param {nsISearchEngine|nsIURI|URL|string} urlOrEngine entity to initiate + * a speculative connection for. + * @param {window} window the window from where the connection is initialized. + * @note This is not infallible, if a speculative connection cannot be + * initialized, it will be a no-op. + */ + setupSpeculativeConnection(urlOrEngine, window) { + if (!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 (urlOrEngine instanceof URL) { + urlOrEngine = urlOrEngine.href; + } + + try { + let uri = + urlOrEngine instanceof Ci.nsIURI + ? urlOrEngine + : Services.io.newURI(urlOrEngine); + Services.io.speculativeConnect( + uri, + window.gBrowser.contentPrincipal, + null + ); + } 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 + * @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 `#`. + * @returns {array} [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; + } + return [spec, prefix, suffix]; + }, + + /** + * Strips a PSL verified public suffix from an hostname. + * @param {string} host A host name. + * @returns {string} Host name without the public suffix. + * @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. + */ + 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 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 } + ); + }); + }, + + /** + * 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 + * @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, if present, two + * slashes. If the given string is not actually a URL, 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, then [prefix, remainder]. Otherwise, ["", str]. + */ + stripURLPrefix(str) { + const REGEXP_STRIP_PREFIX = /^[a-z]+:(?:\/){0,2}/i; + let match = REGEXP_STRIP_PREFIX.exec(str); + if (!match) { + return ["", str]; + } + let prefix = match[0]; + if (prefix.length < str.length && str[prefix.length] == " ") { + return ["", str]; + } + return [prefix, str.substr(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 = BrowserWindowTracker.getTopWindow() + ) { + if (!searchString) { + throw new Error("Must pass a non-null search string"); + } + + let options = { + allowAutofill: false, + isPrivate: PrivateBrowsingUtils.isWindowPrivate(window), + maxResults: 1, + searchString, + userContextId: window.gBrowser.selectedBrowser.getAttribute( + "usercontextid" + ), + allowSearchSuggestions: false, + providers: ["UnifiedComplete", "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 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 = Log.repository.getLogger("urlbar"); + this._logger.manageLevelFromPref("browser.urlbar.loglevel"); + this._logger.addAppender( + new Log.ConsoleAppender(new 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 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 > SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + FormHistory.update( + { + op: "bump", + fieldname: input.formHistoryName, + value, + source, + }, + { + handleError: reject, + handleCompletion: resolve, + } + ); + }); + }, + + /** + * Extracts a telemetry type from a result, used by scalars and event + * telemetry. + * + * @param {UrlbarResult} result The result to analyze. + * @returns {string} A string type for 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. + */ + 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"; + } + return result.payload.suggestion ? "searchsuggestion" : "searchengine"; + case UrlbarUtils.RESULT_TYPE.URL: + if (result.autofill) { + return "autofill"; + } + if ( + result.source == UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL && + result.heuristic + ) { + return "visiturl"; + } + 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"; + } + return "dynamic"; + } + return "unknown"; + }, +}; + +XPCOMUtils.defineLazyGetter(UrlbarUtils.ICON, "DEFAULT", () => { + return 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.jsm. + */ +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: { + displayUrl: { + type: "string", + }, + engine: { + type: "string", + }, + icon: { + type: "string", + }, + inPrivateWindow: { + type: "boolean", + }, + isPinned: { + type: "boolean", + }, + isPrivateEngine: { + 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", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.URL]: { + type: "object", + required: ["url"], + properties: { + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + isPinned: { + type: "boolean", + }, + isSponsored: { + type: "boolean", + }, + sendAttributionRequest: { + type: "boolean", + }, + tags: { + type: "array", + items: { + type: "string", + }, + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + [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: { + content: { + type: "string", + }, + icon: { + type: "string", + }, + keyword: { + type: "string", + }, + title: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.REMOTE_TAB]: { + type: "object", + required: ["device", "url"], + properties: { + device: { + type: "string", + }, + displayUrl: { + type: "string", + }, + icon: { + type: "string", + }, + title: { + type: "string", + }, + url: { + type: "string", + }, + }, + }, + [UrlbarUtils.RESULT_TYPE.TIP]: { + type: "object", + required: ["type"], + properties: { + // Prefer `buttonTextData` if your string is translated. This is for + // untranslated strings. + buttonText: { + type: "string", + }, + // l10n { id, args } + buttonTextData: { + type: "object", + required: ["id"], + properties: { + id: { + type: "string", + }, + args: { + type: "array", + }, + }, + }, + buttonUrl: { + type: "string", + }, + helpUrl: { + type: "string", + }, + icon: { + type: "string", + }, + // Prefer `text` if your string is translated. This is for untranslated + // strings. + text: { + type: "string", + }, + // l10n { id, args } + textData: { + 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: [ + "extension", + "intervention_clear", + "intervention_refresh", + "intervention_update_ask", + "intervention_update_refresh", + "intervention_update_restart", + "intervention_update_web", + "searchTip_onboard", + "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. + */ +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.allowSearchSuggestions] + * Whether to allow search suggestions. This is a veto, meaning that when + * false, suggestions will not be fetched, but when true, some other + * condition may still prohibit suggestions, like private browsing mode. + * Defaults to true. + * @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 [ + ["allowSearchSuggestions", v => true, true], + ["currentPage", v => typeof v == "string" && !!v.length], + ["formHistoryName", v => typeof v == "string" && !!v.length], + ["providers", v => Array.isArray(v) && v.length], + ["searchMode", v => v && typeof v == "object"], + ["sources", v => Array.isArray(v) && v.length], + ]) { + 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. + */ + 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, + }; + } 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. + */ + get fixupError() { + if (!this.fixupInfo) { + return this._fixupError; + } + + return null; + } +} + +/** + * Base class for a muxer. + * The muxer scope is to sort a given list of results. + */ +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. + */ +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) { + Cu.reportError(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. + * @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) { + // Override this with your clean-up on cancel code. + } + + /** + * Called when a result from the provider is picked, but currently only for + * tip and dynamic results. The provider should handle the pick. For tip + * results, this is called only when the tip's payload doesn't have a URL. + * For dynamic results, this is called when any selectable element in the + * result's view is picked. + * + * @param {UrlbarResult} result + * The result that was picked. + * @param {Element} element + * The element in the result's view that was picked. + * @abstract + */ + pickResult(result, element) {} + + /** + * 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: start, + * engagement, abandonment, discard. + */ + onEngagement(isPrivate, state) {} + + /** + * 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 pickResult. 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; + } + + /** + * 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. + * @see UrlbarEventBufferer + * @note UrlbarEventBufferer has a timeout after which user events will be + * processed regardless. + */ + 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; + */ +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); + resolve(); + }, + time, + Ci.nsITimer.TYPE_ONE_SHOT + ); + this._log(`Started`); + }); + + let firePromise = new Promise(resolve => { + this.fire = () => { + this._log(`Skipped`); + resolve(); + return this.promise; + }; + }); + + this.promise = Promise.race([timerPromise, firePromise]).then(() => { + // If we've been canceled, don't call back. + if (this._timer && callback) { + 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. + * @returns {promise} Resolved once all the cancelation work is complete. + */ + cancel() { + this._log(`Canceling`); + this._timer.cancel(); + delete this._timer; + return this.fire(); + } + + _log(msg, isError = false) { + let line = `SkippableTimer :: ${this.name} :: ${msg}`; + if (this.logger) { + this.logger.debug(line); + } + if (isError) { + Cu.reportError(line); + } + } +} diff --git a/browser/components/urlbar/UrlbarValueFormatter.jsm b/browser/components/urlbar/UrlbarValueFormatter.jsm new file mode 100644 index 0000000000..3708521c8e --- /dev/null +++ b/browser/components/urlbar/UrlbarValueFormatter.jsm @@ -0,0 +1,498 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarValueFormatter"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +/** + * Applies URL highlighting and other styling to the text in the urlbar input, + * depending on the text. + */ +class UrlbarValueFormatter { + /** + * @param {UrlbarInput} 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() { + // _getUrlMetaData does URI fixup, which depends on the search service, so + // make sure it's initialized. 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 bail if delayed startup + // isn't finished. + if (!this.window.gBrowserInit.delayedStartupFinished) { + return; + } + if (!Services.search.isInitialized) { + let instance = (this._updateInstance = {}); + await Services.search.init(); + if (this._updateInstance != instance) { + return; + } + delete this._updateInstance; + } + + // 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; + } + }); + } + + _getUrlMetaData() { + if (this.urlbarInput.focused) { + return null; + } + + let url = this.inputField.value; + 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.url == url) { + return browser._urlMetaData.data; + } + browser._urlMetaData = { url, 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 (PrivateBrowsingUtils.isWindowPrivate(this.window)) { + flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT; + } + let uriInfo; + try { + uriInfo = Services.uriFixup.getFixupURIInfo(url, 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 trimmedLength = 0; + if (uriInfo.fixedURI.scheme == "http" && !url.startsWith("http://")) { + url = "http://" + url; + trimmedLength = "http://".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 (!UrlbarPrefs.get("trimURLs") || schemeWSlashes != "http://") { + this.scheme.value = schemeWSlashes; + this.inputField.style.setProperty( + "--urlbar-scheme-size", + schemeWSlashes.length + "ch" + ); + } + + this._ensureFormattedHostVisible(urlMetaData); + + if (!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 (!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.querySelector(":-moz-lwtheme") || + (AppConstants.platform == "win" && + this.window.matchMedia("(-moz-windows-default-theme: 0)").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 == 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.jsm b/browser/components/urlbar/UrlbarView.jsm new file mode 100644 index 0000000000..c4c41e2c2d --- /dev/null +++ b/browser/components/urlbar/UrlbarView.jsm @@ -0,0 +1,2232 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["UrlbarView"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Services: "resource://gre/modules/Services.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarSearchOneOffs: "resource:///modules/UrlbarSearchOneOffs.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "styleSheetService", + "@mozilla.org/content/style-sheet-service;1", + "nsIStyleSheetService" +); + +// Stale rows are removed on a timer with this timeout. Tests can override this +// by setting UrlbarView.removeStaleRowsTimeout. +const DEFAULT_REMOVE_STALE_ROWS_TIMEOUT = 400; + +// Query selector for selectable elements in tip and dynamic results. +const SELECTABLE_ELEMENT_SELECTOR = "[role=button], [selectable=true]"; + +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. + */ +class UrlbarView { + /** + * @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._rows.addEventListener("mousedown", this); + this._rows.addEventListener("mouseup", 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); + + // `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); + + for (let viewTemplate of UrlbarView.dynamicViewTemplatesByName.values()) { + if (viewTemplate.stylesheet) { + addDynamicStylesheet(this.window, viewTemplate.stylesheet); + } + } + } + + get oneOffSearchButtons() { + if (!this._oneOffSearchButtons) { + this._oneOffSearchButtons = new 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() { + return !( + this._queryContext && + this._queryContext.results[0] && + this._queryContext.results[0].heuristic + ); + } + + 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 val; + } + + 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.`); + } + this._selectElement(items[val]); + return val; + } + + get selectedElementIndex() { + if (!this.isOpen || !this._selectedElement) { + return -1; + } + + return this._selectedElement.elementIndex; + } + + set selectedElementIndex(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 val; + } + + let selectableElement = this._getFirstSelectableElement(); + while (selectableElement && selectableElement.elementIndex != val) { + selectableElement = this._getNextSelectableElement(selectableElement); + } + + if (!selectableElement) { + throw new Error(`UrlbarView: Index ${val} is out of bounds.`); + } + + this._selectElement(selectableElement); + return val; + } + + /** + * @returns {UrlbarResult} + * The currently selected result. + */ + get selectedResult() { + if (!this.isOpen) { + return null; + } + + let selectedRow = this._getSelectedRow(); + + if (!selectedRow) { + return null; + } + + return selectedRow.result; + } + + /** + * @returns {Element} + * The currently selected element. + */ + get selectedElement() { + if (!this.isOpen) { + return null; + } + + return this._selectedElement; + } + + /** + * 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 {number} + * The number of selectable elements in the view. + */ + get visibleElementCount() { + let sum = 0; + let element = this._getFirstSelectableElement(); + while (element) { + if (this._isElementVisible(element)) { + sum++; + } + element = this._getNextSelectableElement(element); + } + 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) { + if (!this.isOpen) { + return null; + } + + let row = this._getRowFromElement(element); + + if (!row) { + return null; + } + + return row.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; + } + + /** + * 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. + * @returns {Element} + * The closest element that can be picked including the element itself, or + * null if there is no such element. + */ + getClosestSelectableElement(element) { + let row = element.closest(".urlbarView-row"); + if (!row) { + return null; + } + let closest = row; + if ( + row.result.type == UrlbarUtils.RESULT_TYPE.TIP || + row.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + closest = element.closest(SELECTABLE_ELEMENT_SELECTOR); + } + return this._isElementVisible(closest) ? closest : null; + } + + /** + * @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 {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." + ); + } + + // 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 skipAnnouncement = + selectedElt?.result?.providerName == "TabToSearch" && + !this._announceTabToSearchOnSelection && + userPressedTab && + 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; + }; + + // 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(); + } + + 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; + } + if (!this._isElementVisible(next)) { + continue; + } + selectedElement = next; + } + this._selectElement(selectedElement, { + setAccessibleFocus: !isSkippableTabToSearchAnnounce(selectedElement), + }); + } + + removeAccessibleFocus() { + this._setAccessibleFocus(null); + } + + clear() { + this._rows.textContent = ""; + this.panel.setAttribute("noresults", "true"); + this.clearSelection(); + } + + /** + * Closes the view, cancelling the query if necessary. + * @param {boolean} [elementPicked] + * True if the view is being closed because a result was picked. + */ + close(elementPicked = false) { + this.controller.cancelQuery(); + + if (!this.isOpen) { + return; + } + + // 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.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); + } + + /** + * 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 {Event} queryOptions Options to use when starting a new query. The + * event property is mandatory for proper telemetry tracking. + * @returns {boolean} Whether the view was opened. + */ + autoOpen(queryOptions = {}) { + if (this._pickSearchTipIfPresent(queryOptions.event)) { + return false; + } + + if (!queryOptions.event) { + return false; + } + + if ( + !this.input.value || + this.input.getAttribute("pageproxystate") == "valid" + ) { + if ( + !this.isOpen && + ["mousedown", "command"].includes(queryOptions.event.type) + ) { + this.input.startQuery(queryOptions); + 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 && queryOptions.event.type != "tabswitch") { + return false; + } + + if ( + this._rows.firstElementChild && + this._queryContext.searchString == this.input.value + ) { + // We can reuse the current results. + queryOptions.allowAutofill = this._queryContext.allowAutofill; + } else { + // To reduce results flickering, try to reuse a cached UrlbarQueryContext. + 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"; + + if ( + this._queryContext && + this._queryContext.results && + this._queryContext.results.length + ) { + 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); + return true; + } + + // UrlbarController listener methods. + onQueryStarted(queryContext) { + this._queryWasCancelled = false; + this._queryUpdatedResults = false; + this._openPanelInstance = null; + if (!queryContext.searchString) { + this._previousTabToSearchEngine = null; + } + this._startRemoveStaleRowsTimer(); + } + + onQueryCancelled(queryContext) { + this._queryWasCancelled = true; + this._cancelRemoveStaleRowsTimer(); + } + + onQueryFinished(queryContext) { + this._cancelRemoveStaleRowsTimer(); + if (this._queryWasCancelled) { + return; + } + + // If the query finished and it returned some results, remove stale rows. + if (this._queryUpdatedResults) { + this._removeStaleRows(); + return; + } + + // The query didn't return any results. Clear the view. + this.clear(); + + // 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(queryContext); + + 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) && + queryContext.trimmedSearchString[0] != "@" && + (queryContext.trimmedSearchString[0] != + 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. + this._selectElement(this._getFirstSelectableElement(), { + updateInput: false, + setAccessibleFocus: this.controller._userSelectionBehavior == "arrow", + }); + } 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" && + UrlbarPrefs.get("accessibility.tabToSearch.announceResults") && + this._previousTabToSearchEngine != secondResult.payload.engine + ) { + let engine = secondResult.payload.engine; + this.window.A11yUtils.announce({ + id: UrlbarUtils.WEB_ENGINE_NAMES.has(engine) + ? "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; + } + } + + /** + * 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(). + * {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 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 BrowserWindowTracker.orderedWindows) { + removeDynamicStylesheet(window, viewTemplate.stylesheet); + } + } + } + + // Private methods below. + + _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.setAttribute("open", "true"); + this.input.startLayoutExtend(); + + this.window.addEventListener("resize", this); + this.window.addEventListener("blur", this); + + this.controller.notify(this.controller.NOTIFICATIONS.VIEW_OPEN); + } + + /** + * 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 == 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 {number} firstSearchSuggestionIndex Index of the first search suggestion. + * @param {number} lastSearchSuggestionIndex Index of the last search suggestion. + * @returns {boolean} Whether the row can be updated to this result. + */ + _rowCanUpdateToResult( + rowIndex, + result, + firstSearchSuggestionIndex, + lastSearchSuggestionIndex + ) { + // The heuristic result must always be current, thus it's always compatible. + if (result.heuristic) { + return true; + } + let row = this._rows.children[rowIndex]; + 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 && rowIndex >= firstSearchSuggestionIndex; + } + + _updateResults(queryContext) { + // 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. + + // Find where are existing search suggestions. + let firstSearchSuggestionIndex = -1; + let lastSearchSuggestionIndex = -1; + for (let i = 0; i < this._rows.children.length; ++i) { + let row = this._rows.children[i]; + // Mark every row as stale, _updateRow will unmark them later. + row.setAttribute("stale", "true"); + // Skip any row that isn't a search suggestion, or is non-visible because + // over maxResults. + if ( + row.result.heuristic || + i >= queryContext.maxResults || + !this._resultIsSearchSuggestion(row.result) + ) { + continue; + } + if (firstSearchSuggestionIndex == -1) { + firstSearchSuggestionIndex = i; + } + lastSearchSuggestionIndex = i; + } + + // 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 = queryContext.results; + let resultIndex = 0; + // We can have more rows than the visible ones. + for ( + let rowIndex = 0; + rowIndex < this._rows.children.length && resultIndex < results.length; + ++rowIndex + ) { + let row = this._rows.children[rowIndex]; + let result = results[resultIndex]; + if ( + this._rowCanUpdateToResult( + rowIndex, + result, + firstSearchSuggestionIndex, + lastSearchSuggestionIndex + ) + ) { + this._updateRow(row, result); + resultIndex++; + } + } + // Add remaining results, if we have fewer rows than results. + for (; resultIndex < results.length; ++resultIndex) { + let row = this._createRow(); + this._updateRow(row, results[resultIndex]); + // Due to stale rows, we may have more rows than maxResults, thus we must + // hide them, and we'll revert this when stale rows are removed. + if (this._rows.children.length >= queryContext.maxResults) { + this._setRowVisibility(row, false); + } + this._rows.appendChild(row); + } + + this._updateIndices(); + } + + _createRow() { + let item = this._createElement("div"); + item.className = "urlbarView-row"; + item.setAttribute("role", "option"); + item._elements = new Map(); + return item; + } + + _createRowContent(item) { + // 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.className = "urlbarView-title"; + noWrap.appendChild(title); + item._elements.set("title", title); + + let tagsContainer = this._createElement("span"); + tagsContainer.className = "urlbarView-tags"; + 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); + } + + _createRowContentForTip(item) { + // We use role="group" so screen readers will read the group's label when a + // button inside it gets focus. (Screen readers don't do this for + // role="option".) We set aria-labelledby for the group in _updateIndices. + item._content.setAttribute("role", "group"); + + let favicon = this._createElement("img"); + favicon.className = "urlbarView-favicon"; + favicon.setAttribute("data-l10n-id", "urlbar-tip-icon-description"); + item._content.appendChild(favicon); + item._elements.set("favicon", favicon); + + let title = this._createElement("span"); + title.className = "urlbarView-title"; + item._content.appendChild(title); + item._elements.set("title", title); + + let buttonSpacer = this._createElement("span"); + buttonSpacer.className = "urlbarView-tip-button-spacer"; + item._content.appendChild(buttonSpacer); + + let tipButton = this._createElement("span"); + tipButton.className = "urlbarView-tip-button"; + tipButton.setAttribute("role", "button"); + item._content.appendChild(tipButton); + item._elements.set("tipButton", tipButton); + + let helpIcon = this._createElement("span"); + helpIcon.className = "urlbarView-tip-help"; + helpIcon.setAttribute("role", "button"); + helpIcon.setAttribute("data-l10n-id", "urlbar-tip-help-icon"); + item._elements.set("helpButton", helpIcon); + item._content.appendChild(helpIcon); + + // 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); + } + + _createRowContentForDynamicType(item, result) { + let { dynamicType } = result.payload; + let viewTemplate = UrlbarView.dynamicViewTemplatesByName.get(dynamicType); + this._buildViewForDynamicType( + dynamicType, + item._content, + item._elements, + viewTemplate + ); + } + + _buildViewForDynamicType(type, parentNode, elementsByName, template) { + // Add classes to parentNode's classList. + for (let className of template.classList || []) { + parentNode.classList.add(className); + } + // Set attributes on parentNode. + for (let [name, value] of Object.entries(template.attributes || {})) { + if (name == "id") { + // We do not allow dynamic results to set IDs for their Nodes. IDs are + // managed by the view to ensure they are unique. + Cu.reportError( + "Dynamic results are prohibited from setting their own IDs." + ); + continue; + } + parentNode.setAttribute(name, value); + } + 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); + } + } + + _updateRow(item, result) { + let oldResult = item.result; + let oldResultType = item.result && item.result.type; + item.result = result; + item.removeAttribute("stale"); + item.id = getUniqueId("urlbarView-row-"); + + let needsNewContent = + oldResultType === undefined || + (oldResultType == UrlbarUtils.RESULT_TYPE.TIP) != + (result.type == UrlbarUtils.RESULT_TYPE.TIP) || + (oldResultType == UrlbarUtils.RESULT_TYPE.DYNAMIC) != + (result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) || + (oldResultType == UrlbarUtils.RESULT_TYPE.DYNAMIC && + result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC && + oldResult.dynamicType != result.dynamicType); + + if (needsNewContent) { + if (item._content) { + item._content.remove(); + item._elements.clear(); + } + item._content = this._createElement("span"); + item._content.className = "urlbarView-row-inner"; + item.appendChild(item._content); + item.removeAttribute("dynamicType"); + if (item.result.type == UrlbarUtils.RESULT_TYPE.TIP) { + this._createRowContentForTip(item); + } else if (item.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + this._createRowContentForDynamicType(item, result); + } else { + this._createRowContent(item); + } + } + + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + !result.payload.providesSearchMode && + !result.payload.inPrivateWindow + ) { + item.setAttribute("type", "search"); + } else if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { + item.setAttribute("type", "remotetab"); + } else if (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { + item.setAttribute("type", "switchtab"); + } else if (result.type == UrlbarUtils.RESULT_TYPE.TIP) { + item.setAttribute("type", "tip"); + this._updateRowForTip(item, result); + return; + } else if (result.source == UrlbarUtils.RESULT_SOURCE.BOOKMARKS) { + item.setAttribute("type", "bookmark"); + } else if (result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + item.setAttribute("type", "dynamic"); + this._updateRowForDynamicType(item, result); + return; + } else if (result.providerName == "TabToSearch") { + item.setAttribute("type", "tabtosearch"); + } else { + item.removeAttribute("type"); + } + + let favicon = item._elements.get("favicon"); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == UrlbarUtils.RESULT_TYPE.KEYWORD + ) { + favicon.src = this._iconForResult(result); + } else { + favicon.src = result.payload.icon || 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"); + } + + title._tooltip = result.title; + if (title.hasAttribute("overflow")) { + title.setAttribute("title", title._tooltip); + } + + let tagsContainer = item._elements.get("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; + switch (result.type) { + case UrlbarUtils.RESULT_TYPE.TAB_SWITCH: + actionSetter = () => { + this.document.l10n.setAttributes( + action, + "urlbar-result-action-switch-tab" + ); + }; + setURL = true; + break; + case UrlbarUtils.RESULT_TYPE.REMOTE_TAB: + actionSetter = () => { + action.removeAttribute("data-l10n-id"); + action.textContent = result.payload.device; + }; + setURL = true; + break; + case UrlbarUtils.RESULT_TYPE.SEARCH: + if (result.payload.inPrivateWindow) { + if (result.payload.isPrivateEngine) { + actionSetter = () => { + this.document.l10n.setAttributes( + action, + "urlbar-result-action-search-in-private-w-engine", + { engine: result.payload.engine } + ); + }; + } else { + actionSetter = () => { + this.document.l10n.setAttributes( + action, + "urlbar-result-action-search-in-private" + ); + }; + } + } else if (result.providerName == "TabToSearch") { + actionSetter = () => { + this.document.l10n.setAttributes( + action, + UrlbarUtils.WEB_ENGINE_NAMES.has(result.payload.engine) + ? "urlbar-result-action-tabtosearch-web" + : "urlbar-result-action-tabtosearch-other-engine", + { engine: result.payload.engine } + ); + }; + } else if (!result.payload.providesSearchMode) { + actionSetter = () => { + this.document.l10n.setAttributes( + action, + "urlbar-result-action-search-w-engine", + { engine: result.payload.engine } + ); + }; + } + break; + case UrlbarUtils.RESULT_TYPE.KEYWORD: + isVisitAction = result.payload.input.trim() == result.payload.keyword; + break; + case UrlbarUtils.RESULT_TYPE.OMNIBOX: + actionSetter = () => { + action.removeAttribute("data-l10n-id"); + action.textContent = result.payload.content; + }; + break; + default: + if (result.heuristic) { + isVisitAction = true; + } else { + setURL = true; + } + break; + } + + 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 != UrlbarUtils.RESULT_TYPE.TAB_SWITCH + ) { + item.toggleAttribute("sponsored", true); + actionSetter = () => { + this.document.l10n.setAttributes( + action, + "urlbar-result-action-sponsored" + ); + }; + } else { + item.removeAttribute("sponsored"); + } + + let url = item._elements.get("url"); + if (setURL) { + item.setAttribute("has-url", "true"); + this._addTextContentWithHighlights( + url, + result.payload.displayUrl, + result.payloadHighlights.displayUrl || [] + ); + url._tooltip = result.payload.displayUrl; + } else { + item.removeAttribute("has-url"); + url.textContent = ""; + url._tooltip = ""; + } + if (url.hasAttribute("overflow")) { + url.setAttribute("title", url._tooltip); + } + + if (isVisitAction) { + actionSetter = () => { + this.document.l10n.setAttributes(action, "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 = () => { + action.removeAttribute("data-l10n-id"); + action.textContent = ""; + }; + item._originalActionSetter(); + item.removeAttribute("has-action"); + } + + if (!title.hasAttribute("isurl")) { + title.setAttribute("dir", "auto"); + } else { + title.removeAttribute("dir"); + } + } + + _iconForResult(result, iconUrlOverride = null) { + return ( + (result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && + (result.type == UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == UrlbarUtils.RESULT_TYPE.KEYWORD) && + UrlbarUtils.ICON.HISTORY) || + iconUrlOverride || + result.payload.icon || + ((result.type == UrlbarUtils.RESULT_TYPE.SEARCH || + result.type == UrlbarUtils.RESULT_TYPE.KEYWORD) && + UrlbarUtils.ICON.SEARCH_GLASS) || + UrlbarUtils.ICON.DEFAULT + ); + } + + _updateRowForTip(item, result) { + let favicon = item._elements.get("favicon"); + favicon.src = result.payload.icon || UrlbarUtils.ICON.TIP; + favicon.id = item.id + "-icon"; + + let title = item._elements.get("title"); + title.id = item.id + "-title"; + // Add-ons will provide text, rather than l10n ids. + if (result.payload.textData) { + this.document.l10n.setAttributes( + title, + result.payload.textData.id, + result.payload.textData.args + ); + } else { + title.textContent = result.payload.text; + } + + item._content.setAttribute("aria-labelledby", `${favicon.id} ${title.id}`); + + let tipButton = item._elements.get("tipButton"); + tipButton.id = item.id + "-tip-button"; + // Add-ons will provide buttonText, rather than l10n ids. + if (result.payload.buttonTextData) { + this.document.l10n.setAttributes( + tipButton, + result.payload.buttonTextData.id, + result.payload.buttonTextData.args + ); + } else { + tipButton.textContent = result.payload.buttonText; + } + + let helpIcon = item._elements.get("helpButton"); + helpIcon.id = item.id + "-tip-help"; + helpIcon.style.display = result.payload.helpUrl ? "" : "none"; + + if (result.providerName == "UrlbarProviderSearchTips") { + // 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.textData); + } + } + + 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 = UrlbarProvidersManager.getProvider(result.providerName); + let viewUpdate = await provider.getViewUpdate(result, idsByName); + + // Update each node in the view by name. + for (let [nodeName, update] of Object.entries(viewUpdate)) { + let node = item.querySelector(`#${item.id}-${nodeName}`); + for (let [attrName, value] of Object.entries(update.attributes || {})) { + if (attrName == "id") { + // We do not allow dynamic results to set IDs for their Nodes. IDs are + // managed by the view to ensure they are unique. + Cu.reportError( + "Dynamic results are prohibited from setting their own IDs." + ); + continue; + } + node.setAttribute(attrName, value); + } + for (let [styleName, value] of Object.entries(update.style || {})) { + node.style[styleName] = value; + } + if (update.l10n) { + this.document.l10n.setAttributes( + node, + update.l10n.id, + update.l10n.args || undefined + ); + } else if (update.textContent) { + node.textContent = update.textContent; + } + } + } + + _updateIndices() { + let visibleRowsExist = false; + for (let i = 0; i < this._rows.children.length; i++) { + let item = this._rows.children[i]; + item.result.rowIndex = i; + visibleRowsExist = visibleRowsExist || this._isElementVisible(item); + } + let selectableElement = this._getFirstSelectableElement(); + let uiIndex = 0; + while (selectableElement) { + selectableElement.elementIndex = uiIndex++; + selectableElement = this._getNextSelectableElement(selectableElement); + } + if (visibleRowsExist) { + this.panel.removeAttribute("noresults"); + } else { + this.panel.setAttribute("noresults", "true"); + } + } + + _setRowVisibility(row, visible) { + row.style.display = visible ? "" : "none"; + if ( + !visible && + row.result.type != UrlbarUtils.RESULT_TYPE.TIP && + row.result.type != 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); + } + } + + /** + * 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 = element.closest(".urlbarView-row"); + 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(item, { updateInput = true, setAccessibleFocus = true } = {}) { + if (this._selectedElement) { + this._selectedElement.toggleAttribute("selected", false); + this._selectedElement.removeAttribute("aria-selected"); + } + if (item) { + item.toggleAttribute("selected", true); + item.setAttribute("aria-selected", "true"); + } + this._setAccessibleFocus(setAccessibleFocus && item); + this._selectedElement = item; + + let result = item?.closest(".urlbarView-row")?.result; + if (updateInput) { + this.input.setValueFromResult(result); + } else { + this.input.setResultForCurrentValue(result); + } + + let provider = UrlbarProvidersManager.getProvider(result?.providerName); + if (provider) { + provider.tryMethod("onSelection", result, item); + } + } + + /** + * Returns true if the given element is 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 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 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 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 = element.closest(".urlbarView-row"); + if (!row) { + return null; + } + + let next; + if ( + row.result.type == UrlbarUtils.RESULT_TYPE.TIP || + row.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + let selectables = [...row.querySelectorAll(SELECTABLE_ELEMENT_SELECTOR)]; + let index = selectables.indexOf(element); + if (index == selectables.length - 1) { + next = row.nextElementSibling; + } else { + next = selectables[index + 1]; + } + } else { + next = row.nextElementSibling; + } + + if (next && !this._isSelectableElement(next)) { + next = this._getNextSelectableElement(next); + } + + return next; + } + + /** + * Returns the previous 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 = element.closest(".urlbarView-row"); + if (!row) { + return null; + } + + let previous; + if ( + row.result.type == UrlbarUtils.RESULT_TYPE.TIP || + row.result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC + ) { + let selectables = [...row.querySelectorAll(SELECTABLE_ELEMENT_SELECTOR)]; + let index = selectables.indexOf(element); + if (index == 0 || !selectables.length) { + previous = row.previousElementSibling; + } else if (index < 0) { + previous = selectables[selectables.length - 1]; + } else { + previous = selectables[index - 1]; + } + } else { + previous = row.previousElementSibling; + } + + 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() { + if (!this.isOpen || !this._selectedElement) { + return null; + } + let selected = this._selectedElement; + + if (!selected.classList.contains("urlbarView-row")) { + // selected may be an element in a result group, like RESULT_TYPE.TIP. + selected = selected.closest(".urlbarView-row"); + } + + return selected; + } + + /** + * @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) { + if (!this.isOpen || !element) { + return null; + } + + if (!element.classList.contains("urlbarView-row")) { + element = element.closest(".urlbarView-row"); + } + + return element; + } + + _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 {Node} titleNode + * The DOM node for the result's tile. + */ + _setResultTitle(result, titleNode) { + 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.document.l10n.setAttributes( + titleNode, + "urlbar-result-action-search-w-engine", + { engine: result.payload.engine } + ); + return; + } + + titleNode.removeAttribute("data-l10n-id"); + this._addTextContentWithHighlights( + titleNode, + result.title, + result.titleHighlights + ); + } + + /** + * Adds text content to a node, placing substrings that should be highlighted + * inside <em> nodes. + * + * @param {Node} 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 {Node} 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"); + } + } + + _setElementOverflowing(element, overflowing) { + element.toggleAttribute("overflow", overflowing); + if (overflowing) { + 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 != UrlbarUtils.RESULT_TYPE.TIP) { + return false; + } + let tipButton = this._rows.firstElementChild.querySelector( + ".urlbarView-tip-button" + ); + if (!tipButton) { + throw new Error("Expected a tip button"); + } + this.input.pickElement(tipButton, event); + return true; + } + + // 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 = 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 == UrlbarUtils.RESULT_TYPE.SEARCH && + result.source == 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 == 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 = UrlbarUtils.getResultSourceName(localSearchMode.source); + this.document.l10n.setAttributes( + action, + `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.document.l10n.setAttributes( + action, + "urlbar-result-action-search-w-engine", + { 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 || UrlbarUtils.ICON.DEFAULT; + } + } else { + Cu.reportError("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 = 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 (!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); + if (!element) { + // Ignore clicks on elements that can't be selected/picked. + return; + } + this._selectElement(element, { updateInput: false }); + this.controller.speculativeConnect( + this.selectedResult, + this._queryContext, + "mousedown" + ); + } + + _on_mouseup(event) { + if (event.button == 2) { + // Ignore right clicks. + return; + } + let element = this.getClosestSelectableElement(event.target); + if (!element) { + // Ignore clicks on elements that can't be selected/picked. + return; + } + this.input.pickElement(element, event); + } + + _on_overflow(event) { + if ( + event.detail == 1 && + (event.target.classList.contains("urlbarView-url") || + event.target.classList.contains("urlbarView-title")) + ) { + this._setElementOverflowing(event.target, true); + } + } + + _on_underflow(event) { + if ( + event.detail == 1 && + (event.target.classList.contains("urlbarView-url") || + event.target.classList.contains("urlbarView-title")) + ) { + this._setElementOverflowing(event.target, false); + } + } + + _on_resize() { + this._enableOrDisableRowWrap(); + } +} + +UrlbarView.removeStaleRowsTimeout = DEFAULT_REMOVE_STALE_ROWS_TIMEOUT; + +/** + * 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 { + /** + * Constructor. + * @param {number} size The number of entries to keep in the cache. + */ + constructor(size) { + this.size = size; + this._cache = []; + } + + /** + * Adds a new entry to the cache. + * @param {UrlbarQueryContext} queryContext The UrlbarQueryContext to add. + * @note QueryContexts without a searchString or without results are ignored + * and not added. + */ + put(queryContext) { + let searchString = queryContext.searchString; + if (!searchString || !queryContext.results.length) { + 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 styleSheetService.preloadSheetAsync( + uri, + Ci.nsIStyleSheetService.AGENT_SHEET + ); + window.windowUtils.addSheet(sheet, Ci.nsIDOMWindowUtils.AGENT_SHEET); + } catch (ex) { + Cu.reportError(`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) { + Cu.reportError(`Error removing dynamic stylesheet: ${ex}`); + } +} diff --git a/browser/components/urlbar/content/interventions.ftl b/browser/components/urlbar/content/interventions.ftl new file mode 100644 index 0000000000..01f97e61d8 --- /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/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/experiments.rst b/browser/components/urlbar/docs/experiments.rst new file mode 100644 index 0000000000..f1217df391 --- /dev/null +++ b/browser/components/urlbar/docs/experiments.rst @@ -0,0 +1,727 @@ +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 + +.. toctree:: + :caption: Table of Contents + + experiments + +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/index.rst b/browser/components/urlbar/docs/index.rst new file mode 100644 index 0000000000..633bca91b6 --- /dev/null +++ b/browser/components/urlbar/docs/index.rst @@ -0,0 +1,26 @@ +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 + +Most of the address bar code lives in `browser/components/urlbar <https://searchfox.org/mozilla-central/source/browser/components/urlbar/>`_. +A separate and important back-end piece currently is `toolkit/components/places/UnifiedComplete.jsm <https://searchfox.org/mozilla-central/source/toolkit/components/places/UnifiedComplete.jsm>`_, which was carried over from awesomebar and has not yet been rewritten for quantumbar. + +.. toctree:: + + overview + utilities + telemetry + debugging + experiments + contact diff --git a/browser/components/urlbar/docs/overview.rst b/browser/components/urlbar/docs/overview.rst new file mode 100644 index 0000000000..e7306bceea --- /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. + allowSearchSuggestions: // {boolean} Whether to allow search suggestions. + // This is a veto, meaning that when false, + // suggestions will not be fetched, but when true, + // some other condition may still prohibit + // suggestions, like private browsing mode. Defaults + // to true. + + // 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*. + +.. highlight:: JavaScript +.. code:: + + 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. + } + +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. + // textData and buttonTextData are objects containing an l10n id and args. + // If a tip is untranslated it's possible to provide text and buttonText. + // Payload: { icon, textData, buttonTextData, [buttonUrl], [helpUrl] } + 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/telemetry.rst b/browser/components/urlbar/docs/telemetry.rst new file mode 100644 index 0000000000..6ebb5f0d07 --- /dev/null +++ b/browser/components/urlbar/docs/telemetry.rst @@ -0,0 +1,445 @@ +Telemetry +========= + +This section describes existing telemetry probes measuring interaction with the +Address Bar. + +.. toctree:: + :caption: Table of Contents + + telemetry + +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. + +Scalars +------- + +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_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_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. + - ``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. + - ``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. See the `Obsolete probes`_ section below. + + Valid result types are: + + - ``autofill`` + An origin or a URL completed the user typed text inline. + - ``bookmark`` + A bookmarked URL. + - ``dynamic`` + A specially crafted result, often used in experiments when basic types are + not flexible enough for a rich layout. + - ``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. + - ``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. + - ``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. + +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``. + + +Event 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 & 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``, + ``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. + - ``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``, ``UnifiedComplete``, + ``TokenAliasEngines``, ``SearchSuggestions``, ``UrlbarProviderTopSites``. + Values can also be defined by `URLBar provider experiments`_. + + .. _URLBar provider experiments: experiments.html#developing-address-bar-extensions + +Search probes relevant to the Address Bar +----------------------------------------- + +SEARCH_COUNTS + This histogram tracks search engines and Search Access Points. It is augmented + by multiple SAPs, including the urlbar. + It's a keyed histogram, the keys are strings made up of search engine names + and SAP names, for example ``google.urlbar``. + For each key, this records the count of searches made using that engine and SAP. + SAP names can be: + + - ``alias`` This is when using an alias (like ``@google``) in the urlbar. + Note there is often confusion between the terms alias and keyword, and + they may be used inappropriately: aliases refer to search engines, while + keywords refer to bookmarks. We expect no results for this SAP in Firefox + 83+, since urlbar-searchmode replaces it. + - ``abouthome`` + - ``contextmenu`` + - ``newtab`` + - ``searchbar`` + - ``system`` + - ``urlbar`` Except aliases and search mode. + - ``urlbar-searchmode`` Used when the Urlbar is in search mode. + - ``webextension`` + - ``oneoff-urlbar`` + - ``oneoff-searchbar`` + - ``unknown`` This is actually the searchbar, when using the current engine + one-off button. + +browser.engagement.navigation.* + These keyed scalars track search through different SAPs, for example the + urlbar is tracked by ``browser.engagement.navigation.urlbar``. + It counts loads triggered in a subsession from the specified SAP, broken down + by the originating action. + Possible SAPs are: + + - ``urlbar`` Except search mode. + - ``urlbar_searchmode`` Used when the Urlbar is in search mode. + - ``searchbar`` + - ``about_home`` + - ``about_newtab`` + - ``contextmenu`` + - ``webextension`` + - ``system`` Indicates a search from the command line. + + Recorded actions may be: + + - ``search`` + Used for any search from ``contextmenu``, ``system`` and ``webextension``. + - ``search_alias`` + For ``urlbar``, indicates the user confirmed a search through an alias. + - ``search_enter`` + For ``about_home`` and ``about:newtab`` this counts any search. + For the other SAPs it tracks typing and then pressing Enter. + - ``search_formhistory`` + For ``urlbar``, indicates the user picked a form history result. + - ``search_oneoff`` + For ``urlbar`` or ``searchbar``, indicates the user confirmed a search + using a one-off button. + - ``search_suggestion`` + For ``urlbar`` or ``searchbar``, indicates the user confirmed a search + suggestion. + +Obsolete probes +--------------- + +Obsolete histograms +~~~~~~~~~~~~~~~~~~~ + +FX_URLBAR_SELECTED_RESULT_INDEX (OBSOLETE) + This probe tracked the indexes of picked results in the results list. + It was an enumerated histogram with 17 buckets. + +FX_URLBAR_SELECTED_RESULT_TYPE and FX_URLBAR_SELECTED_RESULT_TYPE_2 (from Firefox 78 on) (OBSOLETE) + This probe tracked the types of picked results. + It was an enumerated histogram with 17 buckets: + + 0. autofill + 1. bookmark + 2. history + 3. keyword + 4. searchengine + 5. searchsuggestion + 6. switchtab + 7. tag + 8. visiturl + 9. remotetab + 10. extension + 11. preloaded-top-site + 12. tip + 13. topsite + 14. formhistory + 15. dynamic + 16. tabtosearch + +FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE and FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2 (from Firefox 78 on) (OBSOLETE) + This probe tracked picked result type, for each one it tracked the index where + it appeared. + It was a keyed histogram where the keys were result types (see + FX_URLBAR_SELECTED_RESULT_TYPE above). For each key, this recorded the indexes + of picked results for that result type. + +Obsolete search probes +---------------------- + +navigation.search (OBSOLETE) + This is a legacy and disabled event telemetry that is currently under + discussion for removal or modernization. It can't be enabled through a pref. + it's more or less equivalent to browser.engagement.navigation, but can also + report the picked search engine. 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/moz.build b/browser/components/urlbar/moz.build new file mode 100644 index 0000000000..cdade1db67 --- /dev/null +++ b/browser/components/urlbar/moz.build @@ -0,0 +1,47 @@ +# 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") + +EXTRA_JS_MODULES += [ + "UrlbarController.jsm", + "UrlbarEventBufferer.jsm", + "UrlbarInput.jsm", + "UrlbarMuxerUnifiedComplete.jsm", + "UrlbarPrefs.jsm", + "UrlbarProviderAutofill.jsm", + "UrlbarProviderExtension.jsm", + "UrlbarProviderHeuristicFallback.jsm", + "UrlbarProviderInterventions.jsm", + "UrlbarProviderOmnibox.jsm", + "UrlbarProviderOpenTabs.jsm", + "UrlbarProviderPrivateSearch.jsm", + "UrlbarProviderSearchSuggestions.jsm", + "UrlbarProviderSearchTips.jsm", + "UrlbarProvidersManager.jsm", + "UrlbarProviderTabToSearch.jsm", + "UrlbarProviderTokenAliasEngines.jsm", + "UrlbarProviderTopSites.jsm", + "UrlbarProviderUnifiedComplete.jsm", + "UrlbarResult.jsm", + "UrlbarSearchOneOffs.jsm", + "UrlbarSearchUtils.jsm", + "UrlbarTokenizer.jsm", + "UrlbarUtils.jsm", + "UrlbarValueFormatter.jsm", + "UrlbarView.jsm", +] + +TESTING_JS_MODULES += [ + "tests/UrlbarTestUtils.jsm", +] +BROWSER_CHROME_MANIFESTS += [ + "tests/browser-tips/browser.ini", + "tests/browser/browser.ini", + "tests/ext/browser/browser.ini", +] +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"] + +SPHINX_TREES["/browser/urlbar"] = "docs" diff --git a/browser/components/urlbar/tests/UrlbarTestUtils.jsm b/browser/components/urlbar/tests/UrlbarTestUtils.jsm new file mode 100644 index 0000000000..2150b5364a --- /dev/null +++ b/browser/components/urlbar/tests/UrlbarTestUtils.jsm @@ -0,0 +1,915 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["UrlbarTestUtils"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.jsm", + BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + FormHistoryTestUtils: "resource://testing-common/FormHistoryTestUtils.jsm", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + TestUtils: "resource://testing-common/TestUtils.jsm", + UrlbarController: "resource:///modules/UrlbarController.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +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, + }, + + /** + * 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 _testScope and provide a fallback path. + * @param {object} scope The global scope where tests are being run. + */ + init(scope) { + this._testScope = scope; + if (scope) { + this.Assert = scope.Assert; + this.EventUtils = scope.EventUtils; + } + }, + + /** + * If tests initialize UrlbarTestUtils, they may need to call this function in + * their cleanup callback, or else their scope will affect subsequent tests. + * This is usually only required for tests outside browser/components/urlbar. + */ + uninit() { + this._testScope = 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.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 = false, + selectionStart = -1, + selectionEnd = -1, + } = {}) { + if (this._testScope) { + await this._testScope.SimpleTest.promiseFocus(window); + } else { + await new Promise(resolve => waitForFocus(resolve, window)); + } + window.gURLBar.inputField.focus(); + // Using the value setter in some cases may trim and fetch unexpected + // results, then pick an alternate path. + if (UrlbarPrefs.get("trimURLs") && value != BrowserUtils.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(); + } + + return this.promiseSearchComplete(window); + }, + + /** + * 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); + if (index >= win.gURLBar.view._rows.children.length) { + throw new Error("Not enough results"); + } + return win.gURLBar.view._rows.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 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; + 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 : []; + 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; + } + 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 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 win.gURLBar.view._getSelectedRow() || null; + }, + + /** + * 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; + }, + + /** + * 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 win.gURLBar.view._rows.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 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; + } + if (this._testScope) { + this._testScope.info("Awaiting for the urlbar panel to open"); + } + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewOpen() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + }, + + /** + * 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; + } + if (this._testScope) { + this._testScope.info("Awaiting for the urlbar panel to close"); + } + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewClose() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + }, + + /** + * @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. + * + * @param {Window} window + * The browser window. + * @param {object} expectedSearchMode + * The expected search mode object. + * @note Can only be used if UrlbarTestUtils has been initialized with init(). + */ + 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 (!expectedSearchMode) { + // Check the input's placeholder. + const prefName = + "browser.urlbar.placeholderName" + + (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; + } + + // 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"]; + for (let prop of ignoreProperties) { + if (prop in expectedSearchMode && !(prop in window.gURLBar.searchMode)) { + if (this._testScope) { + this._testScope.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: UrlbarUtils.WEB_ENGINE_NAMES.has(expectedSearchMode.engineName) + ? "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 = 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. + * @param {object} window + * @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. + * @note Can only be used if UrlbarTestUtils has been initialized with init(). + */ + async enterSearchMode(window, searchMode = null) { + // 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 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 }; + if (UrlbarUtils.WEB_ENGINE_NAMES.has(searchMode.engineName)) { + 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. + * @param {object} window + * @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} [waitForSearch] + * Whether the test should wait for a search after exiting search mode. + * Defaults to true. + * @note If neither `backspace` nor `clickClose` is given, we'll default to + * backspacing. + * @note Can only be used if UrlbarTestUtils has been initialized with init(). + */ + 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} resolved when fetching is complete + * @resolves {number} 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 UrlbarController( + Object.assign( + { + input: { + isPrivate: false, + onFirstResult() { + return false; + }, + 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 AddonTestUtils.promiseStartupManager(); + } catch (error) { + if (!error.message.includes("already started")) { + throw error; + } + } + }, +}; + +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 = BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return 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 = BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return 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 = BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return 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 = BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return 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 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 = 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 {array} results + * An array of UrlbarResult objects that will be the provider's results. + * @param {string} [name] + * The provider's name. Provider names should be unique. + * @param {UrlbarUtils.PROVIDER_TYPE} [type] + * The provider's type. + * @param {number} [priority] + * The provider's priority. Built-in providers have a priority of zero. + * @param {number} [addTimeout] + * If non-zero, each result will be added on this timeout. If zero, all + * results will be added immediately and synchronously. + * @param {function} [onCancel] + * If given, a function that will be called when the provider's cancelQuery + * method is called. + */ + constructor({ + results, + name = Math.floor(Math.random() * 100000), + type = UrlbarUtils.PROVIDER_TYPE.PROFILE, + priority = 0, + addTimeout = 0, + onCancel = null, + onSelection = null, + } = {}) { + super(); + this._results = results; + this._name = name; + this._type = type; + this._priority = priority; + this._addTimeout = addTimeout; + this._onCancel = onCancel; + this._onSelection = onSelection; + } + get name() { + return "TestProvider" + 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 => { + setTimeout(() => { + addCallback(this, result); + resolve(); + }, this._addTimeout); + }); + } + } + } + cancelQuery(context) { + if (this._onCancel) { + this._onCancel(); + } + } + + onSelection(result, element) { + if (this._onSelection) { + this._onSelection(result, element); + } + } +} + +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..0c4bb95cc5 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/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_interventions.js] +[browser_picks.js] +[browser_searchTips_interaction.js] +[browser_searchTips.js] +[browser_selection.js] +[browser_updateAsk.js] +[browser_updateRefresh.js] +[browser_updateRestart.js] +[browser_updateWeb.js] 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..87ee03e75d --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_interventions.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.jsm", +}); + +add_task(async function init() { + 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 promiseAlertDialog("cancel", [ + "chrome://global/content/resetProfile.xhtml", + "chrome://global/content/resetProfile.xul", + ]); + }, + }); +}); + +// 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 promiseAlertDialog("cancel", [ + "chrome://browser/content/sanitize.xhtml", + "chrome://browser/content/sanitize.xul", + ]); + }, + }); +}); + +// 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 + ); +}); + +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"]; + + registerCleanupFunction(async () => { + let enginesReloaded2 = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + Services.locale.requestedLocales = originalRequested; + Services.locale.availableLocales = originalAvailable; + await enginesReloaded2; + }); + + 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()); +}); + +/** + * Picks the help button from an Intervention. We spoof the Intervention in this + * test because our withDNSRedirect helper cannot handle the HTTPS SUMO links. + */ +add_task(async function pickHelpButton() { + const helpUrl = "http://example.com/"; + let results = [ + 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, + { + type: UrlbarProviderInterventions.TIP_TYPE.CLEAR, + text: "This is a test tip.", + buttonText: "Done", + helpUrl, + } + ), + ]; + let interventionProvider = new UrlbarTestUtils.TestProvider({ + results, + priority: 2, + }); + UrlbarProvidersManager.registerProvider(interventionProvider); + + registerCleanupFunction(() => { + UrlbarProvidersManager.unregisterProvider(interventionProvider); + }); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + let [result, element] = await awaitTip(SEARCH_STRINGS.CLEAR); + Assert.strictEqual( + result.payload.type, + UrlbarProviderInterventions.TIP_TYPE.CLEAR + ); + + let helpButton = element._elements.get("helpButton"); + Assert.ok(BrowserTestUtils.is_visible(helpButton)); + EventUtils.synthesizeMouseAtCenter(helpButton, {}); + + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, helpUrl); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + 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..9aacddfad0 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_picks.js @@ -0,0 +1,213 @@ +/* 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_task(async function init() { + 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_helpButton() { + 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_helpButton() { + await doTest({ click: true, helpUrl: HELP_URL }); +}); + +// Clicks inside a tip but not on any button. +add_task(async function mouse_insideTipButNotOnButtons() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: "test", + text: "This is a test tip.", + buttonText: "Done", + helpUrl: HELP_URL, + buttonUrl: TIP_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._elements.get("tipButton"), + "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._elements.get("tipButton"), + "The main button element should remain selected" + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +/** + * Runs this test's main checks. + * + * @param {boolean} click + * Pass true to trigger a click, false to trigger an enter key. + * @param {string} buttonUrl + * Pass a URL if picking the main button should open a URL. Pass nothing if + * picking it should call provider.pickResult instead, or if you want to pick + * the help button instead of the main button. + * @param {string} 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: [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + type: "test", + text: "This is a test tip.", + buttonText: "Done", + buttonUrl, + helpUrl, + } + ), + ], + priority: 1, + }); + UrlbarProvidersManager.registerProvider(provider); + + // If we don't expect to load a URL, then override provider.pickResult so we + // can make sure it's called. + let pickedPromise = + !buttonUrl && !helpUrl + ? new Promise(resolve => (provider.pickResult = resolve)) + : null; + + // 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._elements.get("tipButton"); + let helpButton = row._elements.get("helpButton"); + let target = helpUrl ? helpButton : mainButton; + + // If we're picking the tip with the keyboard, arrow down to select the proper + // target. + if (!click) { + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: helpUrl ? 2 : 1 }); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + target, + `${target.className} should be selected.` + ); + } + + // Now pick the target and wait for provider.pickResult to be called if we + // don't expect to load a URL, or wait for the URL to load otherwise. + await Promise.all([ + pickedPromise || BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser), + UrlbarTestUtils.promisePopupClose(window, () => { + if (click) { + EventUtils.synthesizeMouseAtCenter(target, {}); + } else { + EventUtils.synthesizeKey("KEY_Enter"); + } + }), + ]); + + // 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 ? "click" : "enter", + value: "typed", + }, + ], + { category: "urlbar" } + ); + + // Done. + UrlbarProvidersManager.unregisterProvider(provider); + if (tab) { + BrowserTestUtils.removeTab(tab); + } +} 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..aeb2a6cd83 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips.js @@ -0,0 +1,305 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm", + HttpServer: "resource://testing-common/httpd.js", + ProfileAge: "resource://gre/modules/ProfileAge.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProviderSearchTips: "resource:///modules/UrlbarProviderSearchTips.jsm", +}); + +// 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", +]; + +add_task(async function init() { + 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.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."); + + 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 + ); +}); + +// 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); + }); +}); + +// 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 + ); +}); 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..7484749002 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_searchTips_interaction.js @@ -0,0 +1,620 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AppMenuNotifications: "resource://gre/modules/AppMenuNotifications.jsm", + HttpServer: "resource://testing-common/httpd.js", + ProfileAge: "resource://gre/modules/ProfileAge.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProviderSearchTips: "resource:///modules/UrlbarProviderSearchTips.jsm", +}); + +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", +]; + +add_task(async function init() { + 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.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."); + + 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._elements.get("tipButton"); + 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.loadURI(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._elements.get("tipButton"); + 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(); +}); + +// 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(); + 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(); + 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.loadURI(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(); +}); + +// 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.loadURI(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(); +}); + +add_task(async function pickingTipDoesNotDisableOtherKinds() { + UrlbarProviderSearchTips.disableTipsForCurrentSession = false; + 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._elements.get("tipButton"); + 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( + "Test", + "urlbar-test", + null, + box.PRIORITY_INFO_HIGH, + null, + null, + null + ); + // 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.loadURI(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.loadURI(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() { + // Add a mock engine so we don't hit the network loading the SERP. + let engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(engine); + + await doPasteAndGoTest( + "pasteAndGo_nonURL", + "http://example.com/?search=pasteAndGo_nonURL" + ); + + Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(engine); +}); + +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 + ); + EventUtils.synthesizeMouseAtCenter(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..b7834a8518 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_selection.js @@ -0,0 +1,284 @@ +/* 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" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + icon: "", + text: "This is a test intervention.", + buttonText: "Done", + type: "test", + helpUrl: HELP_URL, + 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-tip-button" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 1, + "The first element should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-tip-help" + ), + "The selected element should be the tip help button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 1, + "getSelectedRowIndex should return 1 even though the help button is selected." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 2, + "The third element should be selected." + ); + + // 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_ArrowUp"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-tip-help" + ), + "The selected element should be the tip help button." + ); + + gURLBar.view.close(); + UrlbarProvidersManager.unregisterProvider(provider); +}); + +add_task(async function tipIsOnlyResult() { + let results = [ + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + icon: "", + text: "This is a test intervention.", + buttonText: "Done", + type: "test", + helpUrl: + "https://support.mozilla.org/en-US/kb/delete-browsing-search-download-history-firefox", + } + ), + ]; + + 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_ArrowDown"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-tip-button" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 0, + "The first element should be selected." + ); + + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-tip-help" + ), + "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_ArrowUp"); + Assert.ok( + UrlbarTestUtils.getSelectedElement(window).classList.contains( + "urlbarView-tip-help" + ), + "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" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + icon: "", + text: "This is a test intervention.", + buttonText: "Done", + type: "test", + } + ), + ]; + + 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-tip-button" + ), + "The selected element should be the tip button." + ); + Assert.equal( + UrlbarTestUtils.getSelectedElementIndex(window), + 1, + "The first element should be selected." + ); + + 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-tip-button" + ), + "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..009aaf4609 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateAsk.js @@ -0,0 +1,70 @@ +/* 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() { + // 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..c462a595b7 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateRefresh.js @@ -0,0 +1,48 @@ +/* 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() { + 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" + ); + }, + }); +}); 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..9d6b5e48a5 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateRestart.js @@ -0,0 +1,45 @@ +/* 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]], + }); + + // 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..4db9fb6019 --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/browser_updateWeb.js @@ -0,0 +1,48 @@ +/* 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() { + // 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..f76377b80e --- /dev/null +++ b/browser/components/urlbar/tests/browser-tips/head.js @@ -0,0 +1,754 @@ +/* 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"; + +/* import-globals-from ../../../../../toolkit/mozapps/update/tests/browser/head.js */ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/head.js", + this +); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", + ResetProfile: "resource://gre/modules/ResetProfile.jsm", + UrlbarProviderInterventions: + "resource:///modules/UrlbarProviderInterventions.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.import( + "resource://testing-common/UrlbarTestUtils.jsm" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "SearchTestUtils", () => { + const { SearchTestUtils: module } = ChromeUtils.import( + "resource://testing-common/SearchTestUtils.jsm" + ); + 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", +}; + +add_task(async function init() { + 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 = gEnv.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) { + gEnv.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 (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 {string} searchString + * The search string. + * @param {string} tip + * The expected tip type. + * @param {string/regexp} title + * The expected tip title. + * @param {string/regexp} button + * The expected button title. + * @param {function} 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._elements.get("tipButton").textContent; + if (typeof button == "string") { + Assert.equal(actualButton, button, "Button string"); + } else { + // regexp + Assert.ok(button.test(actualButton), "Button regexp"); + } + + Assert.ok( + BrowserTestUtils.is_visible(element._elements.get("helpButton")), + "Help button visible" + ); + + // 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._elements.get("tipButton"); + 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 {string} searchString + * The search string. + * @param {TIPS} tip + * The expected tip type. + * @param {string} title + * The expected tip title. + * @param {string} button + * The expected button title. + * @param {function} 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._elements.get("tipButton").textContent; + if (typeof button == "string") { + Assert.equal(actualButton, button, "Button string"); + } else { + // regexp + Assert.ok(button.test(actualButton), "Button regexp"); + } + + Assert.ok(BrowserTestUtils.is_visible(element._elements.get("helpButton"))); + + 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 + */ +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); + } +} + +/** + * Copied from BrowserTestUtils.jsm, but lets you listen for any one of multiple + * dialog URIs instead of only one. + * @param {string} buttonAction + * What button should be pressed on the alert dialog. + * @param {array} uris + * The URIs for the alert dialogs. + * @param {function} [func] + * An optional callback. + */ +async function promiseAlertDialogOpen(buttonAction, uris, func) { + let win = await BrowserTestUtils.domWindowOpened(null, async aWindow => { + // The test listens for the "load" event which guarantees that the alert + // class has already been added (it is added when "DOMContentLoaded" is + // fired). + await BrowserTestUtils.waitForEvent(aWindow, "load"); + + return uris.includes(aWindow.document.documentURI); + }); + + if (func) { + await func(win); + return win; + } + + let dialog = win.document.querySelector("dialog"); + dialog.getButton(buttonAction).click(); + + return win; +} + +/** + * Copied from BrowserTestUtils.jsm, but lets you listen for any one of multiple + * dialog URIs instead of only one. + * @param {string} buttonAction + * What button should be pressed on the alert dialog. + * @param {array} uris + * The URIs for the alert dialogs. + * @param {function} [func] + * An optional callback. + */ +async function promiseAlertDialog(buttonAction, uris, func) { + let win = await promiseAlertDialogOpen(buttonAction, uris, func); + return BrowserTestUtils.windowClosed(win); +} + +/** + * 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; + } + Assert.equal(result.heuristic, heuristic); + Assert.equal(result.displayed.title, title); + Assert.equal( + result.element.row._elements.get("tipButton").textContent, + `Okay, Got It` + ); + Assert.ok( + BrowserTestUtils.is_hidden(result.element.row._elements.get("helpButton")) + ); + + 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); + } +} + +/** + * 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(); + sss.clearPreloads(); +} + +function resetSearchTipsProvider() { + Services.prefs.clearUserPref( + `browser.urlbar.tipShownCount.${UrlbarProviderSearchTips.TIP_TYPE.ONBOARD}` + ); + 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); +} 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/authenticate.sjs b/browser/components/urlbar/tests/browser/authenticate.sjs new file mode 100644 index 0000000000..58da655cf9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/authenticate.sjs @@ -0,0 +1,220 @@ +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) { + var match; + var 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. + var query = "?" + request.queryString; + + var expected_user = "", expected_pass = "", realm = "mochitest"; + var proxy_expected_user = "", proxy_expected_pass = "", proxy_realm = "mochi-proxy"; + var huge = false, plugin = false, anonymous = false; + var 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. + + var 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 "Couldn't parse auth header: " + authHeader; + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw "Couldn't decode auth header: " + userpass; + actual_user = match[1]; + actual_pass = match[2]; + } + + var 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 "Couldn't parse auth header: " + authHeader; + + var userpass = base64ToString(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) + throw "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 (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 (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 (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>"); +} + + +// base64 decoder +// +// Yoinked from extensions/xml-rpc/src/nsXmlRpcClient.js because btoa() +// doesn't seem to exist. :-( +/* Convert Base64 data to a string */ +const toBinaryTable = [ + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, + -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63, + 52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, + 15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1, + -1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40, + 41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1 +]; +const base64Pad = '='; + +function base64ToString(data) { + + var result = ''; + var leftbits = 0; // number of bits decoded, but yet to be appended + var leftdata = 0; // bits decoded, but yet to be appended + + // Convert one by one. + for (var i = 0; i < data.length; i++) { + var c = toBinaryTable[data.charCodeAt(i) & 0x7f]; + var padding = (data[i] == base64Pad); + // Skip illegal characters and whitespace + if (c == -1) continue; + + // Collect data into leftdata, update bitcount + leftdata = (leftdata << 6) | c; + leftbits += 6; + + // If we have 8 or more bits, append 8 bits to the result + if (leftbits >= 8) { + leftbits -= 8; + // Append if not padding. + if (!padding) + result += String.fromCharCode((leftdata >> leftbits) & 0xff); + leftdata &= (1 << leftbits) - 1; + } + } + + // If there are any bits left, the base64 string was corrupted + if (leftbits) + throw Components.Exception('Corrupted base64 string'); + + return result; +} diff --git a/browser/components/urlbar/tests/browser/browser.ini b/browser/components/urlbar/tests/browser/browser.ini new file mode 100644 index 0000000000..09b29034a8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser.ini @@ -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/. + +[DEFAULT] +support-files = + dummy_page.html + head.js + head-common.js + +[browser_aboutHomeLoading.js] +skip-if = tsan # Intermittently times out, see 1622698 (frequent on TSan). +[browser_action_searchengine.js] +[browser_action_searchengine_alias.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] +[browser_autocomplete_no_title.js] +[browser_autocomplete_readline_navigation.js] +skip-if = os != "mac" # Mac only feature +[browser_autocomplete_tag_star_visibility.js] +[browser_autoFill_backspaced.js] +[browser_autoFill_canonize.js] +[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_blanking.js] +support-files = + file_blank_but_not_blank.html +[browser_canonizeURL.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_caret_navigation.js] +[browser_closePanelOnClick.js] +[browser_content_opener.js] +[browser_copy_during_load.js] +support-files = + slow-page.sjs +[browser_copying.js] +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] +[browser_dragdropURL.js] +[browser_dynamicResults.js] +support-files = + dynamicResult0.css + dynamicResult1.css +[browser_edit_invalid_url.js] +[browser_enter.js] +[browser_enterAfterMouseOver.js] +[browser_focusedCmdK.js] +[browser_handleCommand_fallback.js] +[browser_hashChangeProxyState.js] +[browser_heuristicNotAddedFirst.js] +[browser_ime_composition.js] +[browser_inputHistory.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_inputHistory_emptystring.js] +[browser_keepStateAcrossTabSwitches.js] +[browser_keywordBookmarklets.js] +[browser_keyword_override.js] +[browser_keywordSearch.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_keywordSearch_postData.js] +support-files = + POSTSearchEngine.xml + print_postdata.sjs +[browser_keyword_select_and_type.js] +[browser_keyword.js] +support-files = + print_postdata.sjs + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_loadRace.js] +[browser_locationBarCommand.js] +skip-if = (os == 'mac' && os_version == '10.14') # bug 1554807 +[browser_locationBarExternalLoad.js] +[browser_locationchange_urlbar_edit_dos.js] +support-files = + file_urlbar_edit_dos.html +[browser_new_tab_urlbar_reset.js] +[browser_oneOffs_contextMenu.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_oneOffs_heuristicRestyle.js] +[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_oneOffs.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_pasteAndGo.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_raceWithTabs.js] +[browser_redirect_error.js] +support-files = redirect_error.sjs +[browser_remoteness_switch.js] +run-if = e10s +[browser_remotetab.js] +[browser_remove_match.js] +[browser_removeUnsafeProtocolsFromURLBarPaste.js] +[browser_restoreEmptyInput.js] +[browser_result_onSelection.js] +[browser_resultSpan.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] +support-files = + dummy_page.html +[browser_searchMode_engineRemoval.js] +[browser_searchMode_excludeResults.js] +[browser_searchMode_heuristic.js] +[browser_searchMode_indicator.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchMode_localOneOffs_actionText.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_searchMode_no_results.js] +[browser_searchMode_oneOffButton.js] +[browser_searchMode_pickResult.js] +[browser_searchMode_preview.js] +[browser_searchMode_sessionStore.js] +skip-if = os == 'mac' && debug && verify # bug 1671045 +[browser_searchMode_setURI.js] +[browser_searchMode_suggestions.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs + searchSuggestionEngineMany.xml +[browser_searchMode_switchTabs.js] +[browser_searchSettings.js] +[browser_searchSingleWordNotification.js] +[browser_searchSuggestions.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_selectionKeyNavigation.js] +[browser_selectStaleResults.js] +support-files = + searchSuggestionEngineSlow.xml + searchSuggestionEngine.sjs +[browser_searchTelemetry.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[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_speculative_connect.js] +support-files = + searchSuggestionEngine2.xml + searchSuggestionEngine.sjs +[browser_speculative_connect_not_with_client_cert.js] +[browser_stop.js] +skip-if = os == 'mac' # macosx1014 fails due to 1485288 +[browser_stop_pending.js] +support-files = + slow-page.sjs +[browser_stopSearchOnSelection.js] +support-files = + searchSuggestionEngineSlow.xml + searchSuggestionEngine.sjs +[browser_suggestedIndex.js] +[browser_switchTab_closesUrlbarPopup.js] +[browser_switchTab_decodeuri.js] +[browser_switchTab_override.js] +[browser_switchToTab_closes_newtab.js] +[browser_switchToTab_fullUrl_repeatedKeydown.js] +[browser_switchToTabHavingURI_aOpenParams.js] +[browser_tabKeyBehavior.js] +[browser_tabMatchesInAwesomebar_perwindowpb.js] +[browser_tabMatchesInAwesomebar.js] +skip-if = fission && os == 'linux' && debug # bug 1590880 +support-files = + moz.png +[browser_tabToSearch.js] +[browser_textruns.js] +[browser_tokenAlias.js] +[browser_top_sites.js] +[browser_top_sites_private.js] +[browser_typed_value.js] +[browser_updateForDomainCompletion.js] +[browser_updateRows.js] +[browser_urlbar_event_telemetry.js] +support-files = + searchSuggestionEngine.xml + searchSuggestionEngine.sjs +[browser_urlbar_selection.js] +skip-if = (os == 'mac') # bug 1570474 +[browser_urlbar_telemetry_dynamic.js] +support-files = + urlbarTelemetryUrlbarDynamic.css +[browser_urlbar_telemetry_extension.js] +tags = search-telemetry +[browser_urlbar_telemetry_places.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_tabtosearch.js] +tags = search-telemetry +[browser_urlbar_telemetry_tip.js] +tags = search-telemetry +[browser_urlbar_telemetry_topsite.js] +tags = search-telemetry +[browser_urlbar_telemetry.js] +tags = search-telemetry +support-files = + urlbarTelemetrySearchSuggestions.sjs + urlbarTelemetrySearchSuggestions.xml +[browser_UrlbarInput_formatValue.js] +[browser_UrlbarInput_hiddenFocus.js] +[browser_UrlbarInput_overflow.js] +[browser_UrlbarInput_overflow_resize.js] +[browser_UrlbarInput_setURI.js] +[browser_UrlbarInput_tooltip.js] +[browser_UrlbarInput_trimURLs.js] + +[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_waitForLoadOrTimeout.js] +skip-if = tsan # Bug 1683730 +[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..00c659f62b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_formatValue.js @@ -0,0 +1,174 @@ +/* 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< >"); + + 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_hiddenFocus.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_hiddenFocus.js new file mode 100644 index 0000000000..70e43e6c42 --- /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..eab30a175d --- /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..050961abe0 --- /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_setURI.js b/browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.js new file mode 100644 index 0000000000..3aabe1ecac --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_UrlbarInput_setURI.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/. */ + +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.loadURI(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..5c182d1b92 --- /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..3c1f3ea8e4 --- /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.loadURI(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") + ? BrowserUtils.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..7ba9ed328c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_aboutHomeLoading.js @@ -0,0 +1,189 @@ +/* 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.import( + "resource:///modules/sessionstore/SessionSaver.jsm" +); +const { TabStateFlusher } = ChromeUtils.import( + "resource:///modules/sessionstore/TabStateFlusher.jsm" +); + +/** + * 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.loadURI(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_action_searchengine.js b/browser/components/urlbar/tests/browser/browser_action_searchengine.js new file mode 100644 index 0000000000..68bef141e5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_action_searchengine.js @@ -0,0 +1,127 @@ +/* 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_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", false], + ], + }); + + const engine = await Services.search.addEngineWithDetails("MozSearch", { + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + const engine2 = await Services.search.addEngineWithDetails( + "MozSearchPrivate", + { + method: "GET", + template: "http://example.com/private?q={searchTerms}", + } + ); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + registerCleanupFunction(async function() { + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); + await Services.search.removeEngine(engine2); + 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", "http://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", "http://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); + + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + await Services.search.setDefaultPrivate(originalEngine); + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await testSearch(win, "MozSearchPrivate", "http://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..b399b66450 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_action_searchengine_alias.js @@ -0,0 +1,77 @@ +/* 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() { + const ICON_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAA" + + "CQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BL" + + "i4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkb" + + "G7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1v" + + "bjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlA" + + "fwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FA" + + "EWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC"; + await Services.search.addEngineWithDetails("MozSearch", { + iconURL: ICON_URI, + alias: "moz", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + let engine = Services.search.getEngineByName("MozSearch"); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + 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() { + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); + 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, + "http://example.com/?q=open+a+search", + "Should have loaded the correct page" + ); +}); 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..92da82dfaf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_backspaced.js @@ -0,0 +1,262 @@ +/* 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"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + + await PlacesTestUtils.addVisits([ + "http://example.com/", + "http://example.com/foo", + ]); + + 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..099fc90417 --- /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..a425ddd4a7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_firstResult.js @@ -0,0 +1,199 @@ +/* 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_task(async function init() { + 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, + }); + + // 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..7da2230ad1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_placeholder.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 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"; + +add_task(async function init() { + await cleanUp(); +}); + +add_task(async function origin() { + await PlacesTestUtils.addVisits("http://example.com/"); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + 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); + + await searchAndCheck("exa", "example.com/"); + await searchAndCheck("EXAM", "EXAMple.com/"); + await searchAndCheck("eXaMp", "eXaMple.com/"); + await searchAndCheck("exampl", "example.com/"); + + 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 Services.search.addEngineWithDetails("Test", { + alias: "@__example", + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + let engine = Services.search.getEngineByName("Test"); + await Services.search.removeEngine(engine); + }); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "@__ex", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "@__example "); + Assert.equal(gURLBar.selectionStart, "@__ex".length); + Assert.equal(gURLBar.selectionEnd, "@__example ".length); + + await searchAndCheck("@__exa", "@__example "); + await searchAndCheck("@__EXAM", "@__EXAMple "); + await searchAndCheck("@__eXaMp", "@__eXaMple "); + await searchAndCheck("@__exampl", "@__example "); + + await cleanUp(); +}); + +add_task(async function noMatch1() { + await PlacesTestUtils.addVisits("http://example.com/"); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + 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); + + // Search with a string that does not match the placeholder. Placeholder + // autofill shouldn't happen. + gURLBar.value = "moz"; + UrlbarTestUtils.fireInputEvent(window); + Assert.equal(gURLBar.value, "moz"); + Assert.equal(gURLBar.selectionStart, "moz".length); + Assert.equal(gURLBar.selectionEnd, "moz".length); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Search for "ex" again. It should be autofilled. Placeholder autofill + // won't happen. It's not important for this test to check that. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "ex", + fireInputEvent: true, + }); + 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); + + // Placeholder autofill should work again for example.com searches. + await searchAndCheck("exa", "example.com/"); + await searchAndCheck("EXAM", "EXAMple.com/"); + await searchAndCheck("eXaMp", "eXaMple.com/"); + await searchAndCheck("exampl", "example.com/"); + + await cleanUp(); +}); + +add_task(async function noMatch2() { + await PlacesTestUtils.addVisits([ + "http://mozilla.org/", + "http://example.com/", + ]); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "moz", + fireInputEvent: true, + }); + let details = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.ok(details.autofill); + Assert.equal(gURLBar.value, "mozilla.org/"); + Assert.equal(gURLBar.selectionStart, "moz".length); + Assert.equal(gURLBar.selectionEnd, "mozilla.org/".length); + + // Search with a string that does not match the placeholder but does trigger + // autofill. Placeholder autofill shouldn't happen. + gURLBar.value = "ex"; + UrlbarTestUtils.fireInputEvent(window); + Assert.equal(gURLBar.value, "ex"); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "ex".length); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gURLBar.value, "example.com/"); + Assert.equal(gURLBar.selectionStart, "ex".length); + Assert.equal(gURLBar.selectionEnd, "example.com/".length); + + // Do some searches that should trigger placeholder autofill. + await searchAndCheck("exa", "example.com/"); + await searchAndCheck("EXAm", "EXAmple.com/"); + + // Search for "moz" again. It should be autofilled. Placeholder autofill + // shouldn't happen. + gURLBar.value = "moz"; + UrlbarTestUtils.fireInputEvent(window); + Assert.equal(gURLBar.value, "moz"); + Assert.equal(gURLBar.selectionStart, "moz".length); + Assert.equal(gURLBar.selectionEnd, "moz".length); + await UrlbarTestUtils.promiseSearchComplete(window); + Assert.equal(gURLBar.value, "mozilla.org/"); + Assert.equal(gURLBar.selectionStart, "moz".length); + Assert.equal(gURLBar.selectionEnd, "mozilla.org/".length); + + // Do some searches that should trigger placeholder autofill. + await searchAndCheck("mozi", "mozilla.org/"); + await searchAndCheck("MOZil", "MOZilla.org/"); + + await cleanUp(); +}); + +add_task(async function clear_placeholder_for_keyword_or_alias() { + info("Clear the autofill placeholder if a keyword is typed"); + await PlacesTestUtils.addVisits("http://example.com/"); + await PlacesUtils.keywords.insert({ + keyword: "ex", + url: "http://somekeyword.com/", + }); + let engine = await Services.search.addEngineWithDetails("AutofillTest", { + alias: "exam", + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + await PlacesUtils.keywords.remove("ex"); + await Services.search.removeEngine(engine); + }); + + // Do an initial search that triggers autofill so that the placeholder has an + // initial value. + 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); + + // The values are initially autofilled on input, then the placeholder is + // removed when the first non-autofill result arrives. + + // Matches the keyword. + await searchAndCheck("ex", "example.com/", "ex"); + await searchAndCheck("EXA", "EXAmple.com/", "EXAmple.com/"); + // Matches the alias. + + await searchAndCheck("eXaM", "eXaMple.com/", "eXaMple.com/"); + await searchAndCheck("examp", "example.com/", "example.com/"); + + await cleanUp(); +}); + +async function searchAndCheck( + searchString, + expectedAutofillValue, + onCompleteValue = "" +) { + gURLBar.value = searchString; + + // Placeholder autofill is done on input, so fire an input event. As the + // comment at the top of this file says, we can't use + // promiseAutocompleteResultPopup() or other helpers that wait for searches to + // complete because we are specifically checking 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, expectedAutofillValue); + Assert.equal(gURLBar.selectionStart, searchString.length); + Assert.equal(gURLBar.selectionEnd, expectedAutofillValue.length); + + await UrlbarTestUtils.promiseSearchComplete(window); + + if (onCompleteValue) { + // Check the final value after the results arrived. + Assert.equal(gURLBar.value, onCompleteValue); + Assert.equal(gURLBar.selectionStart, searchString.length); + Assert.equal(gURLBar.selectionEnd, onCompleteValue.length); + } +} + +async function cleanUp() { + 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_preserve.js b/browser/components/urlbar/tests/browser/browser_autoFill_preserve.js new file mode 100644 index 0000000000..bfd7f6e20e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_preserve.js @@ -0,0 +1,260 @@ +/* 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_task(async function init() { + 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 Services.search.addEngineWithDetails("Test", { + alias: "@example", + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + let engine = Services.search.getEngineByName("Test"); + await Services.search.removeEngine(engine); + }); + 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); + 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..1d4b805681 --- /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_task(async function setup() { + 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..03be7d5424 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autoFill_typed.js @@ -0,0 +1,179 @@ +/* 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_task(async function init() { + 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 Services.search.addEngineWithDetails("Test", { + alias: "@__example", + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + let engine = Services.search.getEngineByName("Test"); + await Services.search.removeEngine(engine); + }); + // 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..bd43e52565 --- /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_task(async function setUp() { + // 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.loadURI( + 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..0f8652248b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_a11y_label.js @@ -0,0 +1,155 @@ +/* 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 SUGGEST_ALL_PREF = "browser.search.suggest.enabled"; +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +async function getResultText(element) { + await initAccessibilityService(); + await BrowserTestUtils.waitForCondition(() => + accService.getAccessibleFor(element) + ); + let accessible = accService.getAccessibleFor(element); + return accessible.name; +} + +let accService; +async function initAccessibilityService() { + if (accService) { + return; + } + accService = Cc["@mozilla.org/accessibilityService;1"].getService( + Ci.nsIAccessibilityService + ); + if (Services.appinfo.accessibilityEnabled) { + return; + } + + async function promiseInitOrShutdown(init = true) { + await new Promise(resolve => { + let observe = (subject, topic, data) => { + Services.obs.removeObserver(observe, "a11y-init-or-shutdown"); + // "1" indicates that the accessibility service is initialized. + if (data === (init ? "1" : "0")) { + resolve(); + } + }; + Services.obs.addObserver(observe, "a11y-init-or-shutdown"); + }); + } + await promiseInitOrShutdown(true); + registerCleanupFunction(async () => { + accService = null; + await promiseInitOrShutdown(false); + }); +} + +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 + ); + is( + await getResultText(element), + "about: robots— Switch to Tab", + "Result a11y label should be: <title>— Switch to Tab" + ); + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); + gBrowser.removeTab(tab); +}); + +add_task(async function searchSuggestions() { + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + 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() { + await Services.search.setDefault(oldDefaultEngine); + 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. + // The extra spaces are here due to bug 1550644. + let searchTerm = "foo "; + let expectedSearches = [searchTerm, "foo foo", "foo bar"]; + 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 + ); + let selected = element.hasAttribute("selected"); + if (!selected) { + // Simulate the result being selected so we see the expanded text. + element.toggleAttribute("selected", true); + } + if (result.searchParams.inPrivateWindow) { + Assert.equal( + await getResultText(element), + searchTerm + "— Search in a Private Window", + "Check result label" + ); + } else { + let suggestion = expectedSearches.shift(); + Assert.equal( + await getResultText(element), + suggestion + + "— Search with browser_searchSuggestionEngine searchSuggestionEngine.xml", + "Check result label" + ); + } + if (!selected) { + element.toggleAttribute("selected", false); + } + } + } + Assert.ok(!expectedSearches.length); +}); 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..750cbb01a9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_autoselect.js @@ -0,0 +1,128 @@ +/* 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" + ); + // Also check the "selected" attribute, to ensure it is not a "fake" selection + // due to binding misbehaviors. + let element = UrlbarTestUtils.getSelectedRow(window); + Assert.ok( + element.hasAttribute("selected"), + "Should have the selected attribute on the row element" + ); + + // 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..51e1972dcc --- /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..7fda51305a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_autocomplete_enter_race.js @@ -0,0 +1,178 @@ +/* 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_task(async function setup() { + 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.keywords.insert({ + keyword: "keyword", + url: "http://example.com/?q=%s", + }); + // Needs at least one success. + ok(true, "Setup complete"); +}); + +add_task( + taskWithNewTab(async function test_keyword() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "keyword bear", + }); + gURLBar.focus(); + EventUtils.sendString("d"); + EventUtils.synthesizeKey("KEY_Enter"); + info("wait for the page to load"); + await BrowserTestUtils.browserLoaded( + gBrowser.selectedTab.linkedBrowser, + false, + "http://example.com/?q=beard" + ); + }) +); + +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 Services.search.addEngineWithDetails("MozSearch", { + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + let engine = Services.search.getEngineByName("MozSearch"); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + 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); + let mozSearchEngine = Services.search.getEngineByName("MozSearch"); + if (mozSearchEngine) { + await Services.search.removeEngine(mozSearchEngine); + } + } + 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, + "http://example.com/?q=ex" + ); + await cleanup(); + }) +); + +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); + }); + + let start = Cu.now(); + 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, + "http://example.com/" + ); + 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..13c9fc3fb5 --- /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..399612738d --- /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..a2dc30fdcc --- /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_blanking.js b/browser/components/urlbar/tests/browser/browser_blanking.js new file mode 100644 index 0000000000..3cfb1d3c1e --- /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_canonizeURL.js b/browser/components/urlbar/tests/browser/browser_canonizeURL.js new file mode 100644 index 0000000000..e15f6a393e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_canonizeURL.js @@ -0,0 +1,255 @@ +/* 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 }, + ], + ]; + + if (Services.prefs.getBoolPref("network.ftp.enabled")) { + // Include FTP testcase only if FTP protocol handler is enabled, otherwise + // the test would hang on external application chooser popup. + testcases.push(["ftp://example", "ftp://example/", { 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], + ], + }); + + for (let [inputValue, expectedURL, options] of testcases) { + info(`Testing input string: "${inputValue}" - expected: "${expectedURL}"`); + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURL, + gBrowser.selectedBrowser + ); + let promiseStopped = BrowserTestUtils.browserStopped( + gBrowser.selectedBrowser, + undefined, + true + ); + gURLBar.focus(); + gURLBar.inputField.value = inputValue.slice(0, -1); + EventUtils.sendString(inputValue.slice(-1)); + EventUtils.synthesizeKey("KEY_Enter", options); + await Promise.all([promiseLoad, promiseStopped]); + } +}); + +add_task(async function checkPrefTurnsOffCanonize() { + // Add a dummy search engine to avoid hitting the network. + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + registerCleanupFunction(async () => + Services.search.setDefault(oldDefaultEngine) + ); + + // Ensure we don't end up loading something in the current tab becuase it's empty: + let initialTab = await BrowserTestUtils.openNewForegroundTab({ + 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(gBrowser.selectedBrowser, false, newURL) + : BrowserTestUtils.waitForNewTab(gBrowser); + + gURLBar.focus(); + gURLBar.selectionStart = gURLBar.selectionEnd = + gURLBar.inputField.value.length; + gURLBar.inputField.value = "exampl"; + EventUtils.sendString("e"); + EventUtils.synthesizeKey("KEY_Enter", { ctrlKey: true }); + + 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( + gBrowser.selectedBrowser.currentURI.spec, + newURL, + "New tab should have navigated" + ); + } + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab, { animate: false }); + } +}); + +add_task(async function autofill() { + // Re-enable autofill and canonization. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.autoFill", true], + ["browser.urlbar.ctrlCanonizesURLs", true], + ], + }); + + // 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. + gURLBar.select(); + EventUtils.sendString("blah"); + + // 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(gURLBar.inputField, "select"); + } + + for (let [inputValue, expectedURL, options] of testcases) { + let promiseLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + expectedURL, + gBrowser.selectedBrowser + ); + gURLBar.select(); + let autofillPromise = promiseAutofill(); + EventUtils.sendString(inputValue); + await autofillPromise; + EventUtils.synthesizeKey("KEY_Enter", options); + await promiseLoad; + + // Here again, make sure autofill isn't disabled for the next search. See + // the comment above. + gURLBar.select(); + EventUtils.sendString("blah"); + } + + await PlacesUtils.history.clear(); +}); + +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]], + }); + + info("Paste the word to the urlbar"); + const testWord = "example"; + simulatePastingToUrlbar(testWord); + is(gURLBar.value, testWord, "Paste the test word correctly"); + + info("Send enter key while pressing the ctrl key"); + EventUtils.synthesizeKey("VK_RETURN", { type: "keydown", ctrlKey: true }); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gBrowser.selectedBrowser.documentURI.spec, + `http://mochi.test:8888/?terms=${testWord}`, + "The loaded url is not canonized" + ); + + EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" }); +}); + +add_task(async function() { + info("Test whether canonization is enabled again after releasing the ctrl"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.ctrlCanonizesURLs", true]], + }); + + info("Paste the word to the urlbar"); + const testWord = "example"; + simulatePastingToUrlbar(testWord); + is(gURLBar.value, testWord, "Paste the test word correctly"); + + info("Release the ctrl key befoer typing Enter key"); + EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" }); + + info("Send enter key with the ctrl"); + const onLoad = BrowserTestUtils.waitForDocLoadAndStopIt( + `https://www.${testWord}.com/`, + gBrowser.selectedBrowser + ); + EventUtils.synthesizeKey("VK_RETURN", { type: "keydown", ctrlKey: true }); + await onLoad; + info("The loaded url is canonized"); +}); + +function simulatePastingToUrlbar(text) { + gURLBar.focus(); + + const keyForPaste = document + .getElementById("key_paste") + .getAttribute("key") + .toLowerCase(); + EventUtils.synthesizeKey(keyForPaste, { type: "keydown", ctrlKey: true }); + + gURLBar.select(); + EventUtils.sendString(text); +} diff --git a/browser/components/urlbar/tests/browser/browser_caret_navigation.js b/browser/components/urlbar/tests/browser/browser_caret_navigation.js new file mode 100644 index 0000000000..890acd30d6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_caret_navigation.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * 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() { + 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") { + if (AppConstants.platform == "linux") { + await checkCaretMoves( + "KEY_ArrowUp", + INITIAL_SELECTION_START, + "Selection should be collapsed to its start" + ); + + gURLBar.selectionStart = INITIAL_SELECTION_START; + gURLBar.selectionEnd = INITIAL_SELECTION_END; + await checkCaretMoves( + "KEY_ArrowDown", + INITIAL_SELECTION_END, + "Selection should be collapsed to its end" + ); + } + + await checkCaretMoves( + "KEY_ArrowDown", + gURLBar.value.length, + "Caret should have moved to the end" + ); + await checkPopupOpens("KEY_ArrowDown"); + + await checkCaretMoves( + "KEY_ArrowUp", + 0, + "Caret should have moved to the start" + ); + await checkPopupOpens("KEY_ArrowUp"); + } else { + await checkPopupOpens("KEY_ArrowDown"); + await checkPopupOpens("KEY_ArrowUp"); + } +}); + +async function checkCaretMoves(key, pos, msg) { + checkIfKeyStartsQuery(key, false); + Assert.equal( + UrlbarTestUtils.isPopupOpen(window), + false, + `${key}: Popup shouldn't be open` + ); + Assert.equal( + gURLBar.selectionStart, + gURLBar.selectionEnd, + `${key}: Input selection should be empty` + ); + Assert.equal(gURLBar.selectionStart, pos, `${key}: ${msg}`); +} + +async function checkPopupOpens(key) { + // Store current selection and check it doesn't change. + let selectionStart = gURLBar.selectionStart; + let selectionEnd = gURLBar.selectionEnd; + await UrlbarTestUtils.promisePopupOpen(window, () => { + checkIfKeyStartsQuery(key, true); + }); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + `${key}: Heuristic result should be selected` + ); + Assert.equal( + gURLBar.selectionStart, + selectionStart, + `${key}: Input selection start should not change` + ); + Assert.equal( + gURLBar.selectionEnd, + selectionEnd, + `${key}: Input selection end should not change` + ); + await UrlbarTestUtils.promisePopupClose(window); +} + +function checkIfKeyStartsQuery(key, shouldStartQuery) { + let queryStarted = false; + let queryListener = { + onQueryStarted() { + queryStarted = true; + }, + }; + gURLBar.controller.addQueryListener(queryListener); + EventUtils.synthesizeKey(key); + gURLBar.eventBufferer.replayDeferredEvents(false); + gURLBar.controller.removeQueryListener(queryListener); + Assert.equal( + queryStarted, + shouldStartQuery, + `${key}: Should${shouldStartQuery ? "" : "n't"} have started a query` + ); +} 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..f7d88ffbf5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_closePanelOnClick.js @@ -0,0 +1,30 @@ +/* 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.synthesizeNativeMouseClickAtCenter(elt) + ); + } + }); +}); 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..1f7b80b21f --- /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_copy_during_load.js b/browser/components/urlbar/tests/browser/browser_copy_during_load.js new file mode 100644 index 0000000000..b24cbe077d --- /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..c1858cffa8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_copying.js @@ -0,0 +1,386 @@ +/* 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.loadURI(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 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/\xe9", + }, + { + // 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.ält.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/?\xf7", + }, + { + 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/би", + }, + + { + 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..4dc199c1f7 --- /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..48fce657ad --- /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..5a294afcbc --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dns_first_for_single_words.js @@ -0,0 +1,49 @@ +/* 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.import("resource://testing-common/Sinon.jsm"); + 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..15273b4285 --- /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_task(async function init() { + 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..c4a4a04749 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dragdropURL.js @@ -0,0 +1,108 @@ +/* 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 Services.search.addEngineWithDetails("MozSearch", { + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + let originalEngine = await Services.search.getDefault(); + let engine = Services.search.getEngineByName("MozSearch"); + await Services.search.setDefault(engine); + + registerCleanupFunction(async function cleanup() { + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]); + } + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); + }); +}); + +/** + * 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 = "http://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 = "http://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..97126f35c3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_dynamicResults.js @@ -0,0 +1,740 @@ +/* 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", + }, + }, + { + 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. +add_task(async function viewUpdated() { + 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")' + ); + } + + // text.textContent should be updated. + Assert.equal( + text.textContent, + `result.payload.searchString is: ${searchString}`, + "text.textContent" + ); + + await UrlbarTestUtils.promisePopupClose(window); + } + }); +}); + +// 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. Arrow down 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_ArrowDown"); + 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"); + } + + // Arrow down again to select the result after the dynamic result. + EventUtils.synthesizeKey("KEY_ArrowDown"); + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 2, + "Row at index 2 selected" + ); + Assert.notEqual( + UrlbarTestUtils.getSelectedRow(window), + row, + "Row is not selected" + ); + + // Arrow back up 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_ArrowUp"); + 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"); + } + + // Arrow up again to select the heuristic result. + EventUtils.synthesizeKey("KEY_ArrowUp"); + 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. Arrow down 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_ArrowDown", { 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. Arrow down 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_ArrowDown", { 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 pickResult 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.loadURI(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, + }, + }, + button2: { + textContent: "Button 2", + attributes: { + searchString: result.payload.searchString, + }, + }, + }; + } + + pickResult(result, element) { + if (this._pickPromiseResolve) { + 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); + 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); + 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 || {})) { + 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..bbebc0f940 --- /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 = BrowserUtils.trimURLProtocol + "invalid.somehost/mytest"; + +add_task(async function setup() { + 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_enter.js b/browser/components/urlbar/tests/browser/browser_enter.js new file mode 100644 index 0000000000..567ccfa834 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_enter.js @@ -0,0 +1,268 @@ +/* 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_task(async function setup() { + const engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + engine.alias = "@default"; + + const defaultEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + registerCleanupFunction(async function() { + Services.search.defaultEngine = defaultEngine; + }); +}); + +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"); + + // 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); +}); + +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 onBeforeUnload = SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return new Promise(resolve => { + content.window.addEventListener("beforeunload", () => { + 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 beforeUnload event in the content. + await onBeforeUnload; + 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 beforeUnload 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); +}); 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..3629774c73 --- /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..c03689d392 --- /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_handleCommand_fallback.js b/browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js new file mode 100644 index 0000000000..51cec4e8be --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_handleCommand_fallback.js @@ -0,0 +1,151 @@ +/* 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(); + let engine = await Services.search.addEngineWithDetails("MozSearch", { + alias: "moz", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + let engine2 = await Services.search.addEngineWithDetails("MozSearch2", { + alias: "@moz", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + 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 () => { + sandbox.restore(); + await Services.search.removeEngine(engine); + await Services.search.removeEngine(engine2); + 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."); + 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..d6d0cf0e3c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_hashChangeProxyState.js @@ -0,0 +1,148 @@ +/* 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_heuristicNotAddedFirst.js b/browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js new file mode 100644 index 0000000000..7a25235d34 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_heuristicNotAddedFirst.js @@ -0,0 +1,165 @@ +/* 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 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: "http://example.com/", + } + ); + nonHeuristicResult.suggestedIndex = 1; + let nonHeuristicProvider = new UrlbarTestUtils.TestProvider({ + results: [nonHeuristicResult], + name: "nonHeuristicProvider", + priority: Infinity, + }); + UrlbarProvidersManager.registerProvider(nonHeuristicProvider); + + // Do a search. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + // The first result should be the heuristic and it should be selected. + let actualHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(actualHeuristic.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal( + UrlbarTestUtils.getSelectedElement(window), + actualHeuristic.element.row + ); + Assert.equal(UrlbarTestUtils.getSelectedElementIndex(window), 0); + + // Check the second result for good measure. + let actualNonHeuristic = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal(actualNonHeuristic.type, UrlbarUtils.RESULT_TYPE.TIP); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + UrlbarProvidersManager.unregisterProvider(nonHeuristicProvider); +}); + +// 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 = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: "http://example.com/", + } + ); + 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. + let searchPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + // 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(window, () => {}); + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Wait for the search to finish. + await searchPromise; + + // The first result should be the heuristic. + let actualHeuristic = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + Assert.equal(actualHeuristic.type, UrlbarUtils.RESULT_TYPE.SEARCH); + + // Check the second result for good measure. + let actualNonHeuristic = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal(actualNonHeuristic.type, UrlbarUtils.RESULT_TYPE.TIP); + + // No result should be selected. + Assert.equal(UrlbarTestUtils.getSelectedElement(window), null); + Assert.equal(UrlbarTestUtils.getSelectedElementIndex(window), -1); + + // The one-off settings button should be selected. + Assert.equal( + gURLBar.view.oneOffSearchButtons.selectedButton, + gURLBar.view.oneOffSearchButtons.settingsButtonCompact + ); + + await UrlbarTestUtils.promisePopupClose(window); + UrlbarProvidersManager.unregisterProvider(heuristicProvider); + UrlbarProvidersManager.unregisterProvider(nonHeuristicProvider); +}); 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..94df57fc7f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_ime_composition.js @@ -0,0 +1,287 @@ +/* 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]], + }); + + 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, + }); + let engine = await Services.search.addEngineWithDetails("Test", { + alias: "@test", + template: `http://example.com/?search={searchTerms}`, + }); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + registerCleanupFunction(async () => { + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); + 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.imeCompositionClosesPanel", val]], + }); + await test_composition(val); + await test_composition_searchMode_preview(val); + await test_composition_tabToSearch(val); + } +}); + +async function test_composition(compositionClosesPanel) { + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "Int", "Check urlbar value"); + composeAndCheckPanel("te", !compositionClosesPanel); + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "Inter", "Check urlbar value"); + composeAndCheckPanel("", !compositionClosesPanel); + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "Int", "Check urlbar value"); + composeAndCheckPanel("te", !compositionClosesPanel); + Assert.equal(gURLBar.value, "Inte", "Check urlbar value"); + composeAndCheckPanel("", !compositionClosesPanel); + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + composeAndCheckPanel("In", !compositionClosesPanel); + Assert.equal(gURLBar.value, "In", "Check urlbar value"); + composeAndCheckPanel("", !compositionClosesPanel); + 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(compositionClosesPanel) { + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + if (!compositionClosesPanel) { + 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(compositionClosesPanel) { + info("Check Tab-to-Search is retained by composition"); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "exa", + }); + + 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", !compositionClosesPanel); + Assert.equal(gURLBar.value, "I", "Check urlbar value"); + if (!compositionClosesPanel) { + await UrlbarTestUtils.promiseSearchComplete(window); + } + // Test that we are in confirmed search mode. + await UrlbarTestUtils.assertSearchMode(window, { + engineName: "Test", + entry: "tabtosearch", + }); + await UrlbarTestUtils.exitSearchMode(window); +} 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..191032a78d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_inputHistory.js @@ -0,0 +1,361 @@ +/* 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) { + 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 = 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() { + PlacesUtils.history.decayFrecency(); + await PlacesTestUtils.promiseAsyncUpdates(); +} + +add_task(async function setup() { + 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( + "Adaptive results should be added at the top up to maxRichResults / 4, 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, + }); + + let expectedBookmarkIndex = Math.floor(n / 4) + 2; + 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. + await bumpScore("http://site.tld/1", "site", { visits: 1, picks: 1 }); + + let url = "http://bookmarked.site.tld/1"; + let bm = await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + title: "test_book", + url, + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + // Search only bookmarks. + ["browser.urlbar.suggest.bookmarks", true], + ["browser.urlbar.suggest.history", false], + ], + }); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "site", + }); + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + Assert.equal(result.url, url, "Check bookmarked result"); + + 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"); + await PlacesUtils.history.clear(); + + 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( + 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, + }); +}); 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..8fc44329ff --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_inputHistory_emptystring.js @@ -0,0 +1,100 @@ +/* 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"); + } + } + ); +} + +async function clearInputHistory() { + await PlacesUtils.withConnectionWrapper("test::clearInputHistory", db => { + return db.executeCached(`DELETE FROM moz_inputhistory`); + }); +} + +const TEST_URL = "http://example.com/"; + +async function do_test(openFn, pickMethod) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + async function(browser) { + await 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_task(async function setup() { + 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.loadURI(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..4e21f232b4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keepStateAcrossTabSwitches.js @@ -0,0 +1,68 @@ +/* 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); +}); 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..8b6d84ec39 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keyword.js @@ -0,0 +1,240 @@ +/* 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_task(async function setup() { + 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]], + }); + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + let defaultEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + registerCleanupFunction(async function() { + Services.search.defaultEngine = defaultEngine; + 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..97220fc268 --- /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 setup() { + 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..5ba5805589 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keywordSearch.js @@ -0,0 +1,61 @@ +/** + * 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_task(async function setup() { + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + }); +}); + +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..8484cd778d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_keywordSearch_postData.js @@ -0,0 +1,78 @@ +/** + * 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_task(async function setup() { + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "POSTSearchEngine.xml" + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + }); +}); + +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..c53adaef73 --- /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..dc907b5ee9 --- /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..4e1e48e5d6 --- /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_task(async function setup() { + 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.loadURI(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..aeffac3045 --- /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_task(async function setup() { + 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..3560179be4 --- /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..925f8b0ac5 --- /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_new_tab_urlbar_reset.js b/browser/components/urlbar/tests/browser/browser_new_tab_urlbar_reset.js new file mode 100644 index 0000000000..fc1319b631 --- /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..16f2f51e14 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs.js @@ -0,0 +1,961 @@ +/* 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_task(async function init() { + 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( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + await Services.search.moveEngine(engine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault.ui.enabled", 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(); + 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-compact" + ), + "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-compact" + ), + "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); + 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); + 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); + 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); + 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, 3, "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; + 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..e0bf868ee9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_heuristicRestyle.js @@ -0,0 +1,506 @@ +/* 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://browser/skin/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]) + : [""]; + 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.fail("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_task(async function init() { + let oldDefaultEngine = await Services.search.getDefault(); + let engine = await Services.search.addEngineWithDetails( + TEST_DEFAULT_ENGINE_NAME, + { + template: `http://example.com/?search={searchTerms}`, + } + ); + await Services.search.setDefault(engine); + 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 Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(engine); + 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..786818b9be --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_keyModifiers.js @@ -0,0 +1,389 @@ +/* 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_task(async function init() { + // Add a search suggestion engine and move it to the front so that it appears + // as the first one-off. + engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + await Services.search.moveEngine(engine, 0); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault.ui.enabled", 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..183e6275df --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_searchSuggestions.js @@ -0,0 +1,354 @@ +/* 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_task(async function init() { + 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( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + gEngine2 = await SearchTestUtils.promiseNewSearchEngine( + 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); + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + + 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..e34284a4f7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_oneOffs_settings.js @@ -0,0 +1,88 @@ +/* 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_task(async function init() { + 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(activateFn) { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async browser => { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "example.com", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + gMaxResults - 1 + ); + + await UrlbarTestUtils.promisePopupClose(window, async () => { + let prefPaneLoaded = TestUtils.topicObserved( + "sync-pane-loaded", + () => true + ); + + activateFn(); + + await prefPaneLoaded; + }); + + Assert.equal( + gBrowser.contentWindow.history.state, + "paneSearch", + "Should have opened the search preferences pane" + ); + } + ); +} + +add_task(async function test_open_settings_with_enter() { + await selectSettings(() => { + EventUtils.synthesizeKey("KEY_ArrowUp"); + + Assert.ok( + UrlbarTestUtils.getOneOffSearchButtons( + window + ).selectedButton.classList.contains("search-setting-button-compact"), + "Should have selected the settings button" + ); + + EventUtils.synthesizeKey("KEY_Enter"); + }); +}); + +add_task(async function test_open_settings_with_click() { + await selectSettings(() => { + UrlbarTestUtils.getOneOffSearchButtons(window).settingsButton.click(); + }); +}); 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..980f84139e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_pasteAndGo.js @@ -0,0 +1,92 @@ +/* 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, "") + ); + EventUtils.synthesizeMouseAtCenter(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, "") + ); + EventUtils.synthesizeMouseAtCenter(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 + ); + EventUtils.synthesizeMouseAtCenter(menuitem, {}); + // Using toSource in order to get the newlines escaped: + info("Paste and go, loading " + url.toSource()); + await browserLoadedPromise; + ok(true, "Successfully loaded " + url); +}); + +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); +} 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..10b581b342 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_percent_encoded.js @@ -0,0 +1,63 @@ +/* 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 + ); + }, + "places" + ); + 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..240392527f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_placeholder.js @@ -0,0 +1,295 @@ +/* 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"; + +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; +const TEST_PRIVATE_ENGINE_BASENAME = "searchSuggestionEngine2.xml"; + +var originalEngine, extraEngine, extraPrivateEngine, expectedString; +var tabs = []; + +var noEngineString; + +add_task(async function setup() { + 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 rootDir = getRootDirectory(gTestPath); + extraEngine = await SearchTestUtils.promiseNewSearchEngine( + rootDir + TEST_ENGINE_BASENAME + ); + extraPrivateEngine = await SearchTestUtils.promiseNewSearchEngine( + rootDir + TEST_PRIVATE_ENGINE_BASENAME + ); + + // 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], + ], + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault(originalEngine); + 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); + + 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); + + 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); + // 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); + 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); + + 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); + + 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); + }); + + 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); + await Services.search.setDefaultPrivate(extraPrivateEngine); + + 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); + await Services.search.setDefaultPrivate(originalEngine); + + 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_search_mode_engine_web() { + // Add our test engine to WEB_ENGINE_NAMES so that it's recognized as a web + // engine. + UrlbarUtils.WEB_ENGINE_NAMES.add(extraEngine.name); + + await doSearchModeTest( + { + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + engineName: extraEngine.name, + }, + { + id: "urlbar-placeholder-search-mode-web-2", + args: { name: extraEngine.name }, + } + ); + + UrlbarUtils.WEB_ENGINE_NAMES.delete(extraEngine.name); +}); + +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 } + ); +}); + +/** + * 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..f799f56c3c --- /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..6a2e8c9e00 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_primary_selection_safe_on_new_tab.js @@ -0,0 +1,71 @@ +/* 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.supportsSelectionClipboard(); +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/unicode", + 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..50b97a77d2 --- /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_raceWithTabs.js b/browser/components/urlbar/tests/browser/browser_raceWithTabs.js new file mode 100644 index 0000000000..852ffd11fc --- /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..c7d72425b9 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_redirect_error.js @@ -0,0 +1,134 @@ +/* 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.loadURI(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..c81e9e232a --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remoteness_switch.js @@ -0,0 +1,51 @@ +"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.loadURI(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..51bb79eac2 --- /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.import( + "resource://services-sync/SyncedTabs.jsm" +); + +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: 1452124677, + }, + ], +}; + +add_task(async function setup() { + 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..3793b69bd0 --- /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 gobbledygook = + "\u000a\u000b\u000c\u000e\u000f\u0010\u0011\u0012\u0013\u0014javascript:foo"; +if (supportsNullBytes) { + gobbledygook = "\u0000" + gobbledygook; +} +pairs.push([gobbledygook, "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..1b227c9ea8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_remove_match.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +XPCOMUtils.defineLazyModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.jsm", +}); + +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( + "onDeleteURI", + uri => uri.spec == TEST_URL, + "history" + ); + + 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 }); + await promiseVisitRemoved; + 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], + ], + }); + + await Services.search.addEngineWithDetails("test", { + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + let engine = Services.search.getEngineByName("test"); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + 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_ArrowDown", { 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(); + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); +}); + +// 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 engine = await Services.search.addEngineWithDetails("test", { + method: "GET", + template: "http://example.com/", + searchGetParams: "q={searchTerms}", + }); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + await Services.search.moveEngine(engine, 0); + + let query = "ciao"; + let url = `http://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(); + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); +}); 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..50ac4044e6 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_restoreEmptyInput.js @@ -0,0 +1,60 @@ +/* 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() { + 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..29f8a50d44 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_resultSpan.js @@ -0,0 +1,264 @@ +/* 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" } + ), + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: "about:about", + } + ), +]; + +const MAX_RESULTS = UrlbarPrefs.get("maxRichResults"); +const TIP_SPAN = UrlbarUtils.getSpanForResult({ + type: UrlbarUtils.RESULT_TYPE.TIP, +}); + +add_task(async function init() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +// 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( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: `about:about#${i}`, + } + ) + ); + } + 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); + + // UnifiedComplete'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( + new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: `about:about#${i}`, + } + ) + ); + } + 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); + + // UnifiedComplete'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; +} 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..329091ccf8 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_result_onSelection.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 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, + { + text: "This is a test tip.", + buttonText: "Done", + type: "test", + helpUrl: "about:about", + } + ), + ]; + + 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", + }); + + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + + while (!oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + Assert.equal(selectionCount, 4, "We selected the four elements 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..911f398614 --- /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_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.autoFill", true]], + }); + // Add some history for the empty panel and autofill. + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + { + uri: "http://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", "http://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 == "http://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([ + "http://example.com/", + "http://mochi.test:8888/", + ]); + registerCleanupFunction(PlacesUtils.history.clear); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.loadURI(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..1b47189145 --- /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..f0ccd59f3e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchFunction.js @@ -0,0 +1,256 @@ +/* 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_task(async function init() { + // Run this in a new tab, to ensure all the locationchange notifications have + // fired. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + aliasEngine = await Services.search.addEngineWithDetails("Test", { + alias: ALIAS, + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + await Services.search.removeEngine(aliasEngine); + 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: "handoff", + }) + ); + Assert.ok(gURLBar.hasAttribute("focused"), "Urlbar is focused"); + + await UrlbarTestUtils.assertSearchMode(window, { + engineName: aliasEngine.name, + entry: "handoff", + }); + await assertUrlbarValue("test"); + assertOneOffButtonsVisible(true); + 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..9410a8a604 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchHistoryLimit.js @@ -0,0 +1,92 @@ +/* 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.import( + "resource://gre/modules/SearchSuggestionController.jsm" +); + +let gEngine; + +add_task(async function setup() { + gEngine = await Services.search.addEngineWithDetails("TestLimit", { + template: "http://example.com/?search={searchTerms}", + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(gEngine); + await UrlbarTestUtils.formHistory.clear(); + + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(gEngine); + 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..0190c57add --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_alias_replacement.js @@ -0,0 +1,280 @@ +/* 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_task(async function setup() { + defaultEngine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + defaultEngine.alias = "@default"; + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(defaultEngine); + aliasEngine = await Services.search.addEngineWithDetails("Test", { + alias: ALIAS, + template: "http://example.com/?search={searchTerms}", + }); + + registerCleanupFunction(async function() { + await Services.search.removeEngine(aliasEngine); + Services.search.setDefault(oldDefaultEngine); + }); +}); + +// 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..4ddb11bff2 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_autofill.js @@ -0,0 +1,137 @@ +/* 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"; + +const DEFAULT_ENGINE_NAME = "Test"; + +add_task(async function setup() { + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([{ uri: "http://example.com/" }]); + } + + let oldDefaultEngine = await Services.search.getDefault(); + let defaultEngine = await Services.search.addEngineWithDetails( + DEFAULT_ENGINE_NAME, + { + template: "http://example.com/?search={searchTerms}", + } + ); + await Services.search.setDefault(defaultEngine); + await Services.search.moveEngine(defaultEngine, 0); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(defaultEngine); + }); +}); + +// 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." + ); +}); + +// 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." + ); +}); 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..ef2fc15d17 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_engineRemoval.js @@ -0,0 +1,103 @@ +/* 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 engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + 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 Services.search.removeEngine(engine); + // 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 engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + 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 Services.search.removeEngine(engine); + + // 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 engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + 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 Services.search.removeEngine(engine); + + // 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..b8deaea9ec --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_excludeResults.js @@ -0,0 +1,210 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", +}); + +add_task(async function setup() { + 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(); + + let oldDefaultEngine = await Services.search.getDefault(); + // 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. + let engine = await Services.search.addEngineWithDetails("Test", { + template: `http://subdomain.example.ca/?search={searchTerms}`, + }); + await Services.search.setDefault(engine); + 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: "http://example.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: 1452124677, + }, + { + type: "tab", + title: "Test Remote 2", + url: "http://example-2.com", + icon: UrlbarUtils.ICON.DEFAULT, + client: "7cqCr77ptzX3", + lastUsed: 1452124677, + }, + ], + }; + + 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 PlacesRemoteTabsAutocompleteProvider. + Services.obs.notifyObservers(null, "weave:engine:sync:finish", "tabs"); + + registerCleanupFunction(async function() { + sandbox.restore(); + weaveXPCService.ready = oldWeaveServiceReady; + SyncedTabs._internal = originalSyncedTabsInternal; + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(engine); + 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() { + let badEngine = await Services.search.addEngineWithDetails("TestMalformed", { + template: `http://example.foobar/?search={searchTerms}`, + }); + + 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, { + 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); + await Services.search.removeEngine(badEngine); +}); 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..0505448d63 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_heuristic.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests heuristic results in search mode. + */ + +"use strict"; + +add_task(async function setup() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + // Add a new mock default engine so we don't hit the network. + let oldDefaultEngine = await Services.search.getDefault(); + let engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + await Services.search.setDefault(engine); + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(engine); + }); + + // 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, + "http://example.com/?search=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..6412021a71 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_indicator.js @@ -0,0 +1,388 @@ +/* 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 DEFAULT_ENGINE_NAME = "Test"; +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_task(async function setup() { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME + ); + + let oldDefaultEngine = await Services.search.getDefault(); + defaultEngine = await Services.search.addEngineWithDetails( + DEFAULT_ENGINE_NAME, + { + template: "http://example.com/?search={searchTerms}", + } + ); + await Services.search.setDefault(defaultEngine); + await Services.search.moveEngine(suggestionsEngine, 0); + + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(defaultEngine); + }); + + // 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]], + }); +}); + +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_localOneOffs_actionText.js b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js new file mode 100644 index 0000000000..1c426a3a8e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_localOneOffs_actionText.js @@ -0,0 +1,462 @@ +/* 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_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + await Services.search.moveEngine(engine, 0); + + await PlacesUtils.history.clear(); + + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + 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://browser/skin/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, + actionVisit, + ] = await document.l10n.formatValues([ + { id: "urlbar-result-action-search-history" }, + { id: "urlbar-result-action-search-tabs" }, + { id: "urlbar-result-action-search-bookmarks" }, + { id: "urlbar-result-action-visit" }, + ]); + + 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.action, + actionVisit, + "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, + `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://browser/skin/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://browser/skin/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://browser/skin/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://browser/skin/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_no_results.js b/browser/components/urlbar/tests/browser/browser_searchMode_no_results.js new file mode 100644 index 0000000000..96da1d9721 --- /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_task(async function setup() { + // 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( + 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..8ebf4152a0 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_oneOffButton.js @@ -0,0 +1,118 @@ +/* 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", + details: { + alias: "@test", + template: "http://example.com/?search={searchTerms}", + }, +}; + +add_task(async function setup() { + const engine = await Services.search.addEngineWithDetails( + TEST_ENGINE.name, + TEST_ENGINE.details + ); + + registerCleanupFunction(async () => { + await Services.search.removeEngine(engine); + }); +}); + +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..98416ee106 --- /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_task(async function setup() { + // 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..d7531f6e7b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_preview.js @@ -0,0 +1,494 @@ +/* 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"; +const TEST_ENGINE_DOMAIN = "example.com"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + // TODO (Bug 1675558) - This should not be a requirement for the whole test. + ["browser.urlbar.update2.emptySearchBehavior", 2], + ], + }); + let testEngine = await Services.search.addEngineWithDetails( + TEST_ENGINE_NAME, + { + alias: "@test", + template: `http://${TEST_ENGINE_DOMAIN}/?search={searchTerms}`, + } + ); + + registerCleanupFunction(async () => { + await Services.search.removeEngine(testEngine); + 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; + if (UrlbarUtils.WEB_ENGINE_NAMES.has(button.engine.name)) { + 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", {}, window); + let index = UrlbarTestUtils.getSelectedRowIndex(window); + result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + let expectedSearchMode = { + engineName: result.searchParams.engine, + isPreview: true, + entry: "keywordoffer", + }; + if (UrlbarUtils.WEB_ENGINE_NAMES.has(result.searchParams.engine)) { + 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", {}, window); + } + + 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", {}, window); + 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", {}, window); + } + 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", {}, window); + } + 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.settingsButtonCompact) { + 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.settingsButtonCompact, + "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("http://example.com/"); + } + await updateTopSites( + sites => + sites && + sites[0]?.searchTopSite && + sites[1]?.url == "http://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.settingsButtonCompact) { + 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([ + "http://1.example.com/", + "http://2.example.com/", + "http://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..fb132eff36 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_sessionStore.js @@ -0,0 +1,313 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.jsm", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.jsm", +}); + +// 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 {array} urls + * Array of string URLs to open. + * @param {number} searchModeTabIndex + * The index of the tab in which to enter search mode. + * @param {boolean} 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} 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); +} + +// Tests that search mode is duplicated when duplicating tabs. Note that tab +// duplication is handled by session store. +add_task(async function duplicateTabs() { + // 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. First we need + // to set TabContextMenu.contextTab because that's how the menu item's command + // determines which tab to duplicate. + window.TabContextMenu.contextTab = gBrowser.selectedTab; + let tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + gBrowser.currentURI.spec + ); + let menuitem = document.getElementById("context_duplicateTab"); + menuitem.click(); + 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(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..aef08b7c91 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_suggestions.js @@ -0,0 +1,585 @@ +/* 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_HISTORICAL_SEARCH_SUGGESTIONS = UrlbarPrefs.get( + "maxHistoricalSearchSuggestions" +); + +let suggestionsEngine; +let defaultEngine; +let expectedFormHistoryResults = []; + +add_task(async function setup() { + suggestionsEngine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + SUGGESTIONS_ENGINE_NAME + ); + + let oldDefaultEngine = await Services.search.getDefault(); + defaultEngine = await Services.search.addEngineWithDetails( + DEFAULT_ENGINE_NAME, + { + template: "http://example.com/?search={searchTerms}", + } + ); + await Services.search.setDefault(defaultEngine); + 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. Add more than + // maxHistoricalSearchSuggestions so we can verify that excess form history is + // added after remote suggestions. + for (let i = 0; i < MAX_HISTORICAL_SEARCH_SUGGESTIONS + 1; 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 Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(defaultEngine); + await UrlbarTestUtils.formHistory.clear(); + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault.ui.enabled", 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, + { + 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."); + // 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.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: defaultEngine.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, 2), + { + 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, + }, + }, + ...expectedFormHistoryResults.slice(2, 4), + ]); + + 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( + 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"), + { + 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}`, + }, + makeSuggestionResult("3"), + makeSuggestionResult("4"), + makeSuggestionResult("5"), + ]); + + await UrlbarTestUtils.exitSearchMode(window, { clickClose: true }); + await UrlbarTestUtils.promisePopupClose(window); + + info("Test again with history before suggestions"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.matchBuckets", "general:5,suggestion:Infinity"]], + }); + + 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..7a32bbc10c --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchMode_switchTabs.js @@ -0,0 +1,305 @@ +/* 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() { + // 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 engine = await Services.search.addEngineWithDetails(engineName, { + template: "http://example.com/?search={searchTerms}", + }); + 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 Services.search.removeEngine(engine); + await SpecialPowers.popPrefEnv(); +}); + +// 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 engine = await Services.search.addEngineWithDetails(engineName, { + template: "http://example.com/?search={searchTerms}", + }); + 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.loadURI( + 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 Services.search.removeEngine(engine); +}); + +// 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..ff89a868df --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSettings.js @@ -0,0 +1,32 @@ +/* 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-compact" + ); + 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..3218d2d30d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSingleWordNotification.js @@ -0,0 +1,356 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +let gDNSResolved = false; +let gRealDNSService = gDNSService; +add_task(async function setup() { + gDNSService = { + asyncResolve() { + gDNSResolved = true; + return gRealDNSService.asyncResolve(...arguments); + }, + }; + registerCleanupFunction(function() { + gDNSService = gRealDNSService; + 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); + 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.querySelector("button").click(); + await docLoadPromise; + } else { + notificationBox.currentNotification.close(); + } + } + 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); + // We can do this since the window will be gone shortly. + delete win.gDNSService; + win.gDNSService = { + asyncResolve() { + gDNSResolved = true; + return gRealDNSService.asyncResolve(...arguments); + }, + }; + } else { + win = window; + } + + // Remove the domain from the whitelist, the notification sould appear, + // unless we are in private browsing mode. + Services.prefs.setBoolPref(pref, false); + + 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); + } + }; +} + +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..d325129788 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchSuggestions.js @@ -0,0 +1,343 @@ +/* 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); + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + await UrlbarTestUtils.formHistory.clear(); + registerCleanupFunction(async function() { + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + await Services.search.setDefault(oldDefaultEngine); + + // 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..219a7610c3 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_searchTelemetry.js @@ -0,0 +1,225 @@ +"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], + ], + }); + + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + + // 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 new Promise(resolve => { + EventUtils.synthesizeNativeMouseMove( + window.document.documentElement, + 0, + 0, + resolve + ); + }); +}); + +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_selectStaleResults.js b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js new file mode 100644 index 0000000000..8732a6d356 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectStaleResults.js @@ -0,0 +1,301 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarView: "resource:///modules/UrlbarView.jsm", +}); + +add_task(async function init() { + // 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 = gURLBar.view._rows.children[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(gURLBar.view._rows.children).filter(r => + gURLBar.view._isElementVisible(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( + getRootDirectory(gTestPath) + "searchSuggestionEngineSlow.xml" + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.moveEngine(engine, 0); + await Services.search.setDefault(engine); + + 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 = gURLBar.view._rows.children[maxResults - 2]; + if (row && row._elements.get("title").textContent == "test2") { + observer.disconnect(); + resolve(); + } + }); + observer.observe(gURLBar.view._rows, { + 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); +}); 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..99f0a41d7d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_selectionKeyNavigation.js @@ -0,0 +1,158 @@ +/* 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_task(async function init() { + 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() { + 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"); + Assert.equal(UrlbarTestUtils.getSelectedRowIndex(window), i); + } + EventUtils.synthesizeKey("KEY_ArrowDown"); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + Assert.equal( + UrlbarTestUtils.getSelectedRowIndex(window), + 0, + "The heuristic autofill result should be selected again" + ); +}); + +add_task(async function upKey() { + 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"); + let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(window); + Assert.ok(oneOffs.selectedButton, "A one-off should now be selected"); + while (oneOffs.selectedButton) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + } + 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"); + 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); +}); 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..9d997f1663 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault.js @@ -0,0 +1,215 @@ +/* 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 +}; + +let aliasEngine; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.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. + let oldDefaultEngine = await Services.search.getDefault(); + let oldDefaultPrivateEngine = await Services.search.getDefaultPrivate(); + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + await Services.search.setDefault(engine); + await Services.search.setDefaultPrivate(engine); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "POSTSearchEngine.xml" + ); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + aliasEngine = await Services.search.addEngineWithDetails("MozSearch", { + alias: "alias", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.setDefaultPrivate(oldDefaultPrivateEngine); + await Services.search.removeEngine(aliasEngine); + 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_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..23d7712de1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_separatePrivateDefault_differentEngine.js @@ -0,0 +1,358 @@ +/* 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_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.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. + let oldDefaultEngine = await Services.search.getDefault(); + let oldDefaultPrivateEngine = await Services.search.getDefaultPrivate(); + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + await Services.search.setDefault(engine); + gPrivateEngine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine2.xml" + ); + await Services.search.setDefaultPrivate(gPrivateEngine); + + // Add another engine in the first one-off position. + let engine2 = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "POSTSearchEngine.xml" + ); + await Services.search.moveEngine(engine2, 0); + + // Add an engine with an alias. + gAliasEngine = await Services.search.addEngineWithDetails("MozSearch", { + alias: "alias", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.setDefaultPrivate(oldDefaultPrivateEngine); + await Services.search.removeEngine(gAliasEngine); + 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_speculative_connect.js b/browser/components/urlbar/tests/browser/browser_speculative_connect.js new file mode 100644 index 0000000000..2b4d132812 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect.js @@ -0,0 +1,202 @@ +/* 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_task(async function setup() { + 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, + }, + ]); + + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldCurrentEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + registerCleanupFunction(async function() { + await PlacesUtils.history.clear(); + Services.search.defaultEngine = oldCurrentEngine; + }); +}); + +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..958a239ec5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_speculative_connect_not_with_client_cert.js @@ -0,0 +1,219 @@ +/* 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.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const certService = Cc["@mozilla.org/security/local-cert-service;1"].getService( + Ci.nsILocalCertService +); +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; + chooseCertificateCalled = true; + return true; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogs"]), +}; + +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 input, output; + + let listener = { + onSocketAccepted(socket, transport) { + info("Accepted TLS client connection"); + let connectionInfo = transport.securityInfo.QueryInterface( + Ci.nsITLSServerConnectionInfo + ); + connectionInfo.setSecurityObserver(listener); + input = transport.openInputStream(0, 0, 0); + output = transport.openOutputStream(0, 0, 0); + }, + + onHandshakeDone(socket, status) { + info("TLS handshake done"); + handshakeDone = true; + + 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) { + // This will fail when we close the speculative connection. + } + }, + }, + 0, + 0, + Services.tm.currentThread + ); + }, + + onStopListening() { + info("onStopListening"); + input.close(); + output.close(); + }, + }; + + tlsServer.setSessionTickets(false); + tlsServer.setRequestClientCertificate(Ci.nsITLSServerSocket.REQUEST_ALWAYS); + + tlsServer.asyncListen(listener); + + return tlsServer; +} + +let server; + +add_task(async function setup() { + 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 = await new Promise((resolve, reject) => { + certService.getOrCreateCert("speculative-connect", { + handleCert(c, rv) { + if (!Components.isSuccessCode(rv)) { + reject(rv); + return; + } + resolve(c); + }, + }); + }); + 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, + }, + ]); + + let overrideBits = + Ci.nsICertOverrideService.ERROR_UNTRUSTED | + Ci.nsICertOverrideService.ERROR_MISMATCH; + certOverrideService.rememberValidityOverride( + "localhost", + server.port, + cert, + overrideBits, + 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..1bc011a31c --- /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, + BrowserUtils.trimURL(goodURL), + "location bar reflects loaded page" + ); + + await typeAndSubmitAndStop(badURL); + is( + gURLBar.value, + BrowserUtils.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, + BrowserUtils.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..3fc05a196f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stopSearchOnSelection.js @@ -0,0 +1,115 @@ +/* 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_task(async function init() { + 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( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.moveEngine(engine, 0); + await Services.search.setDefault(engine); + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + 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..3b9c509f95 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_stop_pending.js @@ -0,0 +1,220 @@ +/* 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); +}); 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_switchTab_closesUrlbarPopup.js b/browser/components/urlbar/tests/browser/browser_switchTab_closesUrlbarPopup.js new file mode 100644 index 0000000000..d172176494 --- /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_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..6f9722776b --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_switchToTabHavingURI_aOpenParams.js @@ -0,0 +1,216 @@ +/* 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_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..d08d129c20 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabKeyBehavior.js @@ -0,0 +1,322 @@ +/* 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_task(async function init() { + for (let i = 0; i < UrlbarPrefs.get("maxRichResults"); i++) { + await PlacesTestUtils.addVisits("http://example.com/" + i); + } + + registerCleanupFunction(PlacesUtils.history.clear); +}); + +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."); + let engineDomain = "example.com"; + let testEngine = await Services.search.addEngineWithDetails("Test", { + template: `http://${engineDomain}/?search={searchTerms}`, + }); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${engineDomain}/`]); + } + + // Search for a tab-to-search result. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + }); + 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(); + await Services.search.removeEngine(testEngine); +}); + +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); +}); + +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 }); + 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) { + let urlbar = document.getElementById("urlbar-container"); + let nextFocusableElement = reverse + ? urlbar.previousElementSibling + : urlbar.nextElementSibling; + info(nextFocusableElement); + while ( + nextFocusableElement && + (!nextFocusableElement.classList.contains("toolbarbutton-1") || + nextFocusableElement.hasAttribute("hidden")) + ) { + nextFocusableElement = reverse + ? nextFocusableElement.previousElementSibling + : nextFocusableElement.nextElementSibling; + } + + 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..0e8ea75c1f --- /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.loadURI(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..04cca75021 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabMatchesInAwesomebar_perwindowpb.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test ensures that we don't move switch between tabs when one is in + * private browsing and the other is normal, or vice-versa. + */ + +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, false); + 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..42cd27ce97 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_tabToSearch.js @@ -0,0 +1,679 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.jsm", +}); + +add_task(async function setup() { + 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], + ], + }); + let testEngine = await Services.search.addEngineWithDetails( + TEST_ENGINE_NAME, + { + alias: "@test", + template: `http://${TEST_ENGINE_DOMAIN}/?search={searchTerms}`, + } + ); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${TEST_ENGINE_DOMAIN}/`]); + } + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + await Services.search.removeEngine(testEngine); + }); +}); + +// 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), + }); + 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: UrlbarUtils.WEB_ENGINE_NAMES.has( + tabToSearchDetails.searchParams.engine + ) + ? "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_Tab"); + 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); +}); + +// 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), + }); + 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.ok(!aadID, "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.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.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); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + }); + 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.ok(!aadID, "aria-activedescendant was not set."); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); + 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), + }); + 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.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, + tabToSearchRow.id, + "aria-activedescendant was moved to the first one-off." + ); + EventUtils.synthesizeKey("KEY_ArrowUp"); + aadID = gURLBar.inputField.getAttribute("aria-activedescendant"); + Assert.equal( + aadID, + tabToSearchRow.id, + "aria-activedescendant was set to the tab-to-search result." + ); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +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 Tab 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); + }); + }); + 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_Tab"); + await UrlbarTestUtils.promiseSearchComplete(window); + await TestUtils.waitForCondition( + () => UrlbarTestUtils.getSelectedRowIndex(window) == 1, + "Wait for tab key to be handled" + ); + await UrlbarTestUtils.assertSearchMode(window, { + engineName: TEST_ENGINE_NAME, + entry: "tabtosearch", + isPreview: true, + }); + + await UrlbarTestUtils.exitSearchMode(window); + await UrlbarTestUtils.promisePopupClose(window); +}); + +// 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), + }); + 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_Tab"); + 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: UrlbarUtils.WEB_ENGINE_NAMES.has( + onboardingElement.result.payload.engine + ) + ? "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); + 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), + }); + 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), + }); + 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), + }); + 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), + }); + 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), + }); + 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); + 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 testEngineMaps = await Services.search.addEngineWithDetails( + `${TEST_ENGINE_NAME}Maps`, + { + template: `http://${TEST_ENGINE_DOMAIN}/maps/?search={searchTerms}`, + } + ); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: TEST_ENGINE_DOMAIN.slice(0, 4), + }); + + 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 Services.search.removeEngine(testEngineMaps); + await UrlbarTestUtils.promisePopupClose(window); + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 3); + delete UrlbarProviderTabToSearch.onboardingInteractionAtTime; + await SpecialPowers.popPrefEnv(); +}); + +// Tests that engines with names containing extended Unicode characters can be +// recognized as general-web engines and that their tab-to-search results +// display the correct string. +add_task(async function extended_unicode_in_engine() { + // Baidu's localized name. We expect this tab-to-search result shows the + // general-web engine string because Baidu is included in WEB_ENGINE_NAMES. + let engineName = "百度"; + let engineDomain = "example-2.com"; + let testEngine = await Services.search.addEngineWithDetails(engineName, { + template: `http://${engineDomain}/?search={searchTerms}`, + }); + for (let i = 0; i < 3; i++) { + await PlacesTestUtils.addVisits([`https://${engineDomain}/`]); + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: engineDomain.slice(0, 4), + }); + let tabToSearchDetails = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + Assert.equal( + tabToSearchDetails.searchParams.engine, + engineName, + "The tab-to-search engine name contains extended Unicode characters." + ); + let [actionTabToSearch] = await document.l10n.formatValues([ + { + id: "urlbar-result-action-tabtosearch-web", + args: { engine: tabToSearchDetails.searchParams.engine }, + }, + ]); + Assert.equal( + tabToSearchDetails.displayed.action, + actionTabToSearch, + "The correct action text is displayed in the tab-to-search result." + ); + + await UrlbarTestUtils.promisePopupClose(window); + await PlacesUtils.history.clear(); + await Services.search.removeEngine(testEngine); +}); 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..f09303cd99 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_textruns.js @@ -0,0 +1,58 @@ +/* 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]], + }); + let engine = await Services.search.addEngineWithDetails("Test", { + template: "http://example.com/?search={searchTerms}", + }); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + 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 Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(engine); + }); + + 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..e0951ff3ec --- /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 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 "]; + +let testEngine; + +// 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_task(async function init() { + // Add a default engine with suggestions, to avoid hitting the network when + // fetching them. + let defaultEngine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + defaultEngine.alias = "@default"; + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(defaultEngine); + testEngine = await Services.search.addEngineWithDetails("Test", { + alias: ALIAS, + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + await Services.search.removeEngine(testEngine); + Services.search.setDefault(oldDefaultEngine); + }); + + // 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: testEngine.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: testEngine.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: testEngine.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: testEngine.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: testEngine.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: testEngine.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 (UrlbarUtils.WEB_ENGINE_NAMES.has(engineName)) { + 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: testEngine.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: testEngine.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: testEngine.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: testEngine.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 engine = await Services.search.addEngineWithDetails(name, { + alias, + template: "http://example.com/?search={searchTerms}", + }); + + 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 Services.search.removeEngine(engine); +}); + +// Tests that we show all engines with a token alias that match the search +// string. +add_task(async function multipleMatchingEngines() { + let testEngineFoo = await Services.search.addEngineWithDetails("TestFoo", { + alias: `${ALIAS}foo`, + template: "http://example-2.com/?search={searchTerms}", + }); + + 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 Services.search.removeEngine(testEngineFoo); +}); + +// 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..080d1fcac5 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites.js @@ -0,0 +1,477 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + NewTabUtils: "resource://gre/modules/NewTabUtils.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_task(async function init() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["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..570e623d57 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_top_sites_private.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + NewTabUtils: "resource://gre/modules/NewTabUtils.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_task(async function init() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["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..a01ee3e08e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_typed_value.js @@ -0,0 +1,67 @@ +/* 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_task(async function setup() { + registerCleanupFunction(async function() { + Services.prefs.clearUserPref("browser.urlbar.autoFill"); + gURLBar.handleRevert(); + await PlacesUtils.history.clear(); + }); + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + + 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_updateForDomainCompletion.js b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js new file mode 100644 index 0000000000..27b097cdd7 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_updateForDomainCompletion.js @@ -0,0 +1,24 @@ +"use strict"; + +/** + * Disable keyword.enabled (so no keyword search), 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]] }); + 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_updateRows.js b/browser/components/urlbar/tests/browser/browser_updateRows.js new file mode 100644 index 0000000000..b764c166bf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_updateRows.js @@ -0,0 +1,240 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests row updating and reuse. + +"use strict"; + +let TEST_BASE_URL = "http://example.com/"; + +add_task(async function init() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); +}); + +// A URL result is replaced with a tip result and then vice versa. +add_task(async function urlToTip() { + // Add some visits that will be matched by a "test" search string. + await PlacesTestUtils.addVisits([ + "http://example.com/testxx", + "http://example.com/test", + ]); + + // Add a provider that returns a tip result when the search string is "testx". + let tipResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "OK", + helpUrl: "http://example.com/", + type: "test", + } + ); + tipResult.suggestedIndex = 1; + let provider = new UrlbarTestUtils.TestProvider({ + results: [tipResult], + }); + provider.isActive = context => context.searchString == "testx"; + UrlbarProvidersManager.registerProvider(provider); + + // Search for "test". + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + // The result at index 1 should be the http://example.com/test visit. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.URL, + { + title: "test visit for http://example.com/test", + tagsContainer: null, + titleSeparator: null, + action: "", + url: TEST_BASE_URL + "test", + }, + ["tipButton", "helpButton"] + ); + + // Type an "x" so that the search string is "testx". + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Now the result at index 1 should be the tip from our provider. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.TIP, + { + title: "This is a test tip.", + tipButton: "OK", + helpButton: null, + }, + ["tagsContainer", "titleSeparator", "action", "url"] + ); + + // Type another "x" so that the search string is "testxx". + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // The result at index 1 should be the http://example.com/testxx visit. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.URL, + { + title: "test visit for http://example.com/testxx", + tagsContainer: null, + titleSeparator: null, + action: "", + url: TEST_BASE_URL + "testxx", + }, + ["tipButton", "helpButton"] + ); + + // Backspace so that the search string is "testx" again. + EventUtils.synthesizeKey("KEY_Backspace"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // The result at index 1 should be the tip again. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.TIP, + { + title: "This is a test tip.", + tipButton: "OK", + helpButton: null, + }, + ["tagsContainer", "titleSeparator", "action", "url"] + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); +}); + +// A tip result is replaced with URL result and then vice versa. +add_task(async function tipToURL() { + // Add a visit that will be matched by a "testx" search string. + await PlacesTestUtils.addVisits("http://example.com/testx"); + + // Add a provider that returns a tip result when the search string is "test" + // or "testxx". + let tipResult = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TIP, + UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + { + text: "This is a test tip.", + buttonText: "OK", + helpUrl: "http://example.com/", + type: "test", + } + ); + tipResult.suggestedIndex = 1; + let provider = new UrlbarTestUtils.TestProvider({ + results: [tipResult], + }); + provider.isActive = context => + ["test", "testxx"].includes(context.searchString); + UrlbarProvidersManager.registerProvider(provider); + + // Search for "test". + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + value: "test", + window, + }); + + // The result at index 1 should be the tip from our provider. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.TIP, + { + title: "This is a test tip.", + tipButton: "OK", + helpButton: null, + }, + ["tagsContainer", "titleSeparator", "action", "url"] + ); + + // Type an "x" so that the search string is "testx". + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // Now the result at index 1 should be the visit. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.URL, + { + title: "test visit for http://example.com/testx", + tagsContainer: null, + titleSeparator: null, + action: "", + url: TEST_BASE_URL + "testx", + }, + ["tipButton", "helpButton"] + ); + + // Type another "x" so that the search string is "testxx". + EventUtils.synthesizeKey("x"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // The result at index 1 should be the tip again. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.TIP, + { + title: "This is a test tip.", + tipButton: "OK", + helpButton: null, + }, + ["tagsContainer", "titleSeparator", "action", "url"] + ); + + // Backspace so that the search string is "testx" again. + EventUtils.synthesizeKey("KEY_Backspace"); + await UrlbarTestUtils.promiseSearchComplete(window); + + // The result at index 1 should be the visit again. + await checkResult( + 1, + UrlbarUtils.RESULT_TYPE.URL, + { + title: "test visit for http://example.com/testx", + tagsContainer: null, + titleSeparator: null, + action: "", + url: TEST_BASE_URL + "testx", + }, + ["tipButton", "helpButton"] + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + UrlbarProvidersManager.unregisterProvider(provider); + await PlacesUtils.history.clear(); +}); + +async function checkResult(index, type, presentElements, absentElements) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal(result.type, type, "Expected result type"); + + for (let [name, value] of Object.entries(presentElements)) { + let element = result.element.row._elements.get(name); + Assert.ok(element, `${name} should be present`); + if (typeof value == "string") { + Assert.equal( + element.textContent, + value, + `${name} value should be correct` + ); + } + } + + for (let name of absentElements) { + let element = result.element.row._elements.get(name); + Assert.ok(!element, `${name} should be absent`); + } +} diff --git a/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js new file mode 100644 index 0000000000..221128d36e --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_event_telemetry.js @@ -0,0 +1,1498 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +const TEST_ENGINE_NAME = "Test"; +const TEST_ENGINE_ALIAS = "@test"; +const TEST_ENGINE_DOMAIN = "example.com"; + +function copyToClipboard(str) { + return new Promise((resolve, reject) => { + waitForClipboard( + str, + () => { + Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper) + .copyString(str); + }, + resolve, + reject + ); + }); +} + +// Each test is a function that executes an urlbar action and returns the +// expected event object, or null if no event is expected. +const tests = [ + /* + * Engagement 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(window, { + 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: "UnifiedComplete", + }, + }; + }, + + 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 button, enter."); + 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); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, 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/", + 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."); + win.gURLBar.select(); + let url = "http://example.com/?q=%s"; + let promise = BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "exa", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != url) { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + let element = UrlbarTestUtils.getSelectedRow(win); + EventUtils.synthesizeMouseAtCenter(element, {}, win); + await promise; + 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: "UnifiedComplete", + }, + }; + }, + + 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", + provider: "Autofill", + }, + }; + }, + + async function(win) { + info("Type something, select bookmark entry, Enter."); + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: "exa", + fireInputEvent: true, + }); + while (win.gURLBar.untrimmedValue != "http://example.com/?q=%s") { + 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: "bookmark", + provider: "UnifiedComplete", + }, + }; + }, + + 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 { + 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"; + let aliasEngine = await Services.search.addEngineWithDetails("AliasTest", { + alias, + template: "http://example.com/?search={searchTerms}", + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window: win, + value: `${alias} `, + }); + + await UrlbarTestUtils.assertSearchMode(window, { + 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; + + await Services.search.removeEngine(aliasEngine); + 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("home-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 & 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"); + EventUtils.synthesizeMouseAtCenter(menuitem, {}, win); + 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", + 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", + 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."); + let defaultEngine = await Services.search.getDefault(); + win.gURLBar.select(); + EventUtils.synthesizeKey("k", { accelKey: true }); + 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; + + 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"); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Enter"); + 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 { + 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", + }, + }; + }, + + /* + * Abandonment 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 UrlbarTestUtils.promisePopupOpen(win, () => { + EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + }); + 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(window); + 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 { + 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"); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(win); + EventUtils.synthesizeKey("KEY_Enter"); + 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 { + 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", + }, + }; + }, +]; + +const noEventTests = [ + async function(win) { + info("Type something, click on search settings."); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "about:blank" }, + async browser => { + win.gURLBar.select(); + let promise = BrowserTestUtils.browserLoaded(browser); + 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(); + let promise = BrowserTestUtils.browserLoaded(browser); + 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-compact"), + "Should have selected the settings button" + ); + EventUtils.synthesizeKey("VK_RETURN", {}, win); + await promise; + } + ); + return null; + }, +]; + +add_task(async function test() { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.eventTelemetry.enabled", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + // Create a new search engine and mark it as default + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + "searchSuggestionEngine.xml" + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + await Services.search.moveEngine(engine, 0); + + let aliasEngine = await Services.search.addEngineWithDetails( + TEST_ENGINE_NAME, + { + alias: TEST_ENGINE_ALIAS, + template: `http://${TEST_ENGINE_DOMAIN}/?search={searchTerms}`, + } + ); + + // 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", + }); + await PlacesTestUtils.addVisits([ + { + uri: "http://mochi.test:8888/", + transition: PlacesUtils.history.TRANSITIONS.TYPED, + }, + ]); + + registerCleanupFunction(async function() { + await Services.search.setDefault(oldDefaultEngine); + await Services.search.removeEngine(aliasEngine); + await PlacesUtils.keywords.remove("kw"); + await PlacesUtils.bookmarks.remove(bm); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(window); + }); + + // This is not necessary after each loop, because assertEvents does it. + Services.telemetry.clearEvents(); + + for (let i = 0; i < tests.length; i++) { + info(`Running test at index ${i}`); + let events = await tests[i](window); + if (!Array.isArray(events)) { + events = [events]; + } + // Always blur to ensure it's not accounted as an additional abandonment. + window.gURLBar.setSearchMode({}); + gURLBar.blur(); + TelemetryTestUtils.assertEvents(events, { category: "urlbar" }); + await UrlbarTestUtils.formHistory.clear(window); + } + + for (let i = 0; i < noEventTests.length; i++) { + info(`Running no event test at index ${i}`); + await noEventTests[i](window); + // Always blur to ensure it's not accounted as an additional abandonment. + gURLBar.blur(); + TelemetryTestUtils.assertEvents([], { category: "urlbar" }); + } +}); + +/** + * 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, + { + text: "This is a test intervention.", + buttonText: "Done", + type: "test", + helpUrl: "about:about", + } + ), + 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_selection.js b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js new file mode 100644 index 0000000000..e890ed17cf --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_selection.js @@ -0,0 +1,299 @@ +/* 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.supportsSelectionClipboard()) { + // Reset the clipboard. + clipboardHelper.copyStringToClipboard( + val, + Services.clipboard.kSelectionClipboard + ); + } +} + +function checkPrimarySelection(expectedVal = "") { + if (Services.clipboard.supportsSelectionClipboard()) { + let primaryAsText = SpecialPowers.getClipboardData( + "text/unicode", + SpecialPowers.Ci.nsIClipboard.kSelectionClipboard + ); + Assert.equal(primaryAsText, expectedVal); + } +} + +add_task(async function setup() { + // 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..1e59b99eb1 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry.js @@ -0,0 +1,1346 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +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) { + const url = + getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml"; + let suggestionEngine = await Services.search.addOpenSearchEngine(url, ""); + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault(suggestionEngine); + + try { + await taskFn(suggestionEngine); + } finally { + await Services.search.setDefault(previousEngine); + await Services.search.removeEngine(suggestionEngine); + } +} + +add_task(async function setup() { + // Create a new search engine. + await Services.search.addEngineWithDetails("MozSearch", { + alias: "mozalias", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + + // Make it the default search engine. + let engine = Services.search.getEngineByName("MozSearch"); + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + // And the first one-off engine. + 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]], + }); + + // Use the default matching bucket configuration. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.matchBuckets", "general:5,suggestion:4"]], + }); + + // Allows UrlbarTestUtils to access this scope's test helpers, like Assert. + UrlbarTestUtils.init(this); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function() { + Services.telemetry.canRecordExtended = oldCanRecord; + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(engine); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + await PlacesUtils.history.clear(); + await UrlbarTestUtils.formHistory.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + UrlbarTestUtils.uninit(); + }); +}); + +add_task(async function test_simpleQuery() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + + let resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, 0, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.searchengine, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "searchengine", + 0, + 1 + ); + + 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 resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, 0, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.searchengine, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "searchengine", + 0, + 1 + ); + + 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 resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, 0, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.searchengine, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "searchengine", + 0, + 1 + ); + + 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() { + 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 + ); + + 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 resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, 3, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.searchsuggestion, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "searchsuggestion", + 3, + 1 + ); + + 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 resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, 1, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.searchsuggestion, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "searchsuggestion", + 1, + 1 + ); + + 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 resultIndexHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ); + let resultTypeHist = TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ); + let resultIndexByTypeHist = TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ); + 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."); + let foobarIndex = 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" } + ); + + // Check the histograms as well. + TelemetryTestUtils.assertHistogram(resultIndexHist, foobarIndex, 1); + + TelemetryTestUtils.assertHistogram( + resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES.formhistory, + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + resultIndexByTypeHist, + "formhistory", + foobarIndex, + 1 + ); + + 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() { + // Override the search telemetry search provider info to + // count in-content SEARCH_COUNTs telemetry for our test engine. + SearchSERPTelemetry.overrideSearchTelemetryForTests([ + { + telemetryId: "example", + searchPageRegexp: "^http://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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content: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 + ); + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "example.in-content:organic:none", + 7 + ); + + await BrowserTestUtils.closeWindow(win); + + // Reset the search provider info. + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + await UrlbarTestUtils.formHistory.clear(); +}); 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..20dadd0d7d --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_dynamic.js @@ -0,0 +1,160 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", + UrlbarView: "resource:///modules/UrlbarView.jsm", +}); + +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 { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + 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..89914b10ba --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_extension.js @@ -0,0 +1,181 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +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 { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_task(async function setup() { + 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], + // Use the default matching bucket configuration. + ["browser.urlbar.matchBuckets", "general:5,suggestion:4"], + // 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_places.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js new file mode 100644 index 0000000000..fb0c5e3b46 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_places.js @@ -0,0 +1,330 @@ +/* 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" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +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 { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_task(async function setup() { + 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], + // Use the default matching bucket configuration. + ["browser.urlbar.matchBuckets", "general:5,suggestion:4"], + // 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"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "visiturl", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_autofill() { + const histograms = snapshotHistograms(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await PlacesTestUtils.addVisits([ + { + uri: "http://example.com/mypage", + title: "example", + transition: Ci.nsINavHistoryService.TRANSITION_TYPED, + }, + ]); + + Services.prefs.setBoolPref("browser.urlbar.autoFill", true); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInAwesomebar("example.com/my"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + assertSearchTelemetryEmpty(histograms.search_hist); + assertTelemetryResults( + histograms, + "autofill", + 0, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter + ); + + BrowserTestUtils.removeTab(tab); +}); 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..be6c0acf51 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_remotetab.js @@ -0,0 +1,211 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SyncedTabs: "resource://services-sync/SyncedTabs.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +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 { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_task(async function setup() { + 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], + // Use the default matching bucket configuration. + ["browser.urlbar.matchBuckets", "general:5,suggestion:4"], + // 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: 1452124677, + }, + ], + }; + + 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..54332259d4 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_searchmode.js @@ -0,0 +1,579 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +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_task(async function setup() { + 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. + const url = + getRootDirectory(gTestPath) + "urlbarTelemetrySearchSuggestions.xml"; + let suggestionEngine = await Services.search.addOpenSearchEngine(url, ""); + suggestionEngine.alias = ENGINE_ALIAS; + engineDomain = suggestionEngine.getResultDomain(); + engineName = suggestionEngine.name; + + // Make it the default search engine. + let originalEngine = await Services.search.getDefault(); + await Services.search.setDefault(suggestionEngine); + + // 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(); + + // Clear historical search suggestions to avoid interference from previous + // tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.maxHistoricalSearchSuggestions", 0]], + }); + + // Allows UrlbarTestUtils to access this scope's test helpers, like Assert. + UrlbarTestUtils.init(this); + + // Make sure to restore the engine once we're done. + registerCleanupFunction(async function() { + Services.telemetry.canRecordExtended = oldCanRecord; + await Services.search.setDefault(originalEngine); + await Services.search.removeEngine(suggestionEngine); + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + UrlbarTestUtils.uninit(); + }); +}); + +// 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 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: 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.search(value, { searchEngine, searchModeEntry: "handoff" }) 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() { + 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); +}); + +// 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), + }); + 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_tabtosearch.js b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js new file mode 100644 index 0000000000..386e4268ea --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tabtosearch.js @@ -0,0 +1,356 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + 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 an onboarding result + * with the correct engine. + * + * @param {string} engineName + * The expected engine name. + */ +async function checkForOnboardingResult(engineName) { + 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." + ); + Assert.equal( + tabToSearchResult.payload.dynamicType, + "onboardTabToSearch", + "The tab-to-search result is an onboarding result." + ); +} + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 0]], + }); + + let engine = await Services.search.addEngineWithDetails(ENGINE_NAME, { + template: `http://${ENGINE_DOMAIN}/?q={searchTerms}`, + }); + + UrlbarTestUtils.init(this); + + registerCleanupFunction(async () => { + UrlbarTestUtils.uninit(); + await Services.search.removeEngine(engine); + }); +}); + +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), + }); + + 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(); + }); +}); + +add_task(async function onboarding_impressions() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.tabToSearch.onboard.interactionsLeft", 3]], + }); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + const firstEngineHost = "example"; + let secondEngine = await Services.search.addEngineWithDetails( + `${ENGINE_NAME}2`, + { template: `http://${firstEngineHost}-2.com/?q={searchTerms}` } + ); + + 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 checkForOnboardingResult(ENGINE_NAME); + } + + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 1 + ); + + info("Type through autofill to second engine hostname. Record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForOnboardingResult(ENGINE_NAME); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForOnboardingResult(`${ENGINE_NAME}2`); + 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. + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 3 + ); + + info("Make a typo and return to autofill. Do not record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-`, + fireInputEvent: true, + }); + await checkForOnboardingResult(`${ENGINE_NAME}2`); + 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 checkForOnboardingResult(`${ENGINE_NAME}2`); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 4 + ); + + info("Cancel then restart autofill. Do not record impression."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: `${firstEngineHost}-2`, + fireInputEvent: true, + }); + await checkForOnboardingResult(`${ENGINE_NAME}2`); + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + EventUtils.synthesizeKey("KEY_Backspace"); + await searchPromise; + Assert.greater( + UrlbarTestUtils.getResultCount(window), + 1, + "Sanity check: we have more than one result." + ); + let result = (await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1)) + .result; + Assert.notEqual( + result.type, + UrlbarUtils.RESULT_TYPE.SEARCH, + "The second result is not a tab-to-search result." + ); + searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + // Type the "." from `example-2.com`. + EventUtils.synthesizeKey("."); + await searchPromise; + await checkForOnboardingResult(`${ENGINE_NAME}2`); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 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 checkForOnboardingResult(`${ENGINE_NAME}2`); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 6 + ); + + info( + "Open a result page and then autofill engine host. Record impression." + ); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: firstEngineHost, + fireInputEvent: true, + }); + await checkForOnboardingResult(ENGINE_NAME); + // 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 checkForOnboardingResult(ENGINE_NAME); + await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur()); + // We clear the scalar this time. + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "urlbar.tips", + "tabtosearch_onboard-shown", + 8 + ); + + await PlacesUtils.history.clear(); + await Services.search.removeEngine(secondEngine); + }); +}); 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..a23fffdb3f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_tip.js @@ -0,0 +1,149 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +function snapshotHistograms() { + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + return { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + TelemetryTestUtils.assertHistogram(histograms.resultMethodHist, method, 1); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + `urlbar.picked.${type}`, + index, + 1 + ); +} + +add_task(async function setup() { + 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, + { + icon: "", + text: "This is a test tip.", + buttonText: "OK", + type: "test", + } + ), + { 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..352e08cf8f --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_urlbar_telemetry_topsite.js @@ -0,0 +1,151 @@ +/* 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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.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 { + resultIndexHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX" + ), + resultTypeHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_TYPE_2" + ), + resultIndexByTypeHist: TelemetryTestUtils.getAndClearKeyedHistogram( + "FX_URLBAR_SELECTED_RESULT_INDEX_BY_TYPE_2" + ), + resultMethodHist: TelemetryTestUtils.getAndClearHistogram( + "FX_URLBAR_SELECTED_RESULT_METHOD" + ), + search_hist: TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"), + }; +} + +function assertTelemetryResults(histograms, type, index, method) { + TelemetryTestUtils.assertHistogram(histograms.resultIndexHist, index, 1); + + TelemetryTestUtils.assertHistogram( + histograms.resultTypeHist, + UrlbarUtils.SELECTED_RESULT_TYPES[type], + 1 + ); + + TelemetryTestUtils.assertKeyedHistogramValue( + histograms.resultIndexByTypeHist, + type, + index, + 1 + ); + + 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.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_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.topsites", true], + ["browser.newtabpage.activity-stream.default.sites", EN_US_TOPSITES], + ], + }); + 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_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..d1053dfc8e --- /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.loadURI(deletedURLTab.linkedBrowser, testURL); + BrowserTestUtils.loadURI(fullURLTab.linkedBrowser, testURL); + BrowserTestUtils.loadURI(partialURLTab.linkedBrowser, testURL); + await Promise.all([loaded1, loaded2, loaded3]); + + testURL = BrowserUtils.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, + "", + 'gURLBar.value should be "" 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..77b11adba8 --- /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..63de190935 --- /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_task(async function setup() { + 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..494f728f75 --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_view_resultTypes_display.js @@ -0,0 +1,319 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SyncedTabs } = ChromeUtils.import( + "resource://services-sync/SyncedTabs.jsm" +); + +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_task(async function setup() { + 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], + // Use the default matching bucket configuration. + ["browser.urlbar.matchBuckets", "general:5,suggestion:4"], + // Turn autofill off. + ["browser.urlbar.autoFill", false], + ], + }); + + let engine = await SearchTestUtils.promiseNewSearchEngine( + getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME + ); + let oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + + registerCleanupFunction(async () => { + await Services.search.setDefault(oldDefaultEngine); + }); + + // 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.synthesizeNativeMouseMove(gURLBar.inputField); +}); + +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: parseInt(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 PlacesRemoteTabsAutocompleteProvider. + 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_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..a2d0d907df --- /dev/null +++ b/browser/components/urlbar/tests/browser/browser_whereToOpen.js @@ -0,0 +1,194 @@ +/* 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..e03b45851c --- /dev/null +++ b/browser/components/urlbar/tests/browser/head-common.js @@ -0,0 +1,85 @@ +/* eslint-env mozilla/frame-script */ + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + HttpServer: "resource://testing-common/httpd.js", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm", + Preferences: "resource://gre/modules/Preferences.jsm", + UrlbarProvider: "resource:///modules/UrlbarUtils.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", +}); + +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.import( + "resource://testing-common/UrlbarTestUtils.jsm" + ); + module.init(this); + return module; +}); + +XPCOMUtils.defineLazyGetter(this, "SearchTestUtils", () => { + const { SearchTestUtils: module } = ChromeUtils.import( + "resource://testing-common/SearchTestUtils.jsm" + ); + 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; + } +} diff --git a/browser/components/urlbar/tests/browser/head.js b/browser/components/urlbar/tests/browser/head.js new file mode 100644 index 0000000000..f8e0527240 --- /dev/null +++ b/browser/components/urlbar/tests/browser/head.js @@ -0,0 +1,114 @@ +/* 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"; + +let sandbox; + +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + ResetProfile: "resource://gre/modules/ResetProfile.jsm", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.jsm", + UrlbarController: "resource:///modules/UrlbarController.jsm", + UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.jsm", + UrlbarUtils: "resource:///modules/UrlbarUtils.jsm", + UrlbarView: "resource:///modules/UrlbarView.jsm", +}); + +/* import-globals-from head-common.js */ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js", + this +); + +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +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"); +} + +/** + * 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.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"); +} + +/** + * 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; +} 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..4175a24805 --- /dev/null +++ b/browser/components/urlbar/tests/browser/print_postdata.sjs @@ -0,0 +1,22 @@ +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 { + var body = new BinaryInputStream(request.bodyInputStream); + + var avail; + var bytes = []; + + while ((avail = body.available()) > 0) + Array.prototype.push.apply(bytes, body.readByteArray(avail)); + + var 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..64c6f143b0 --- /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/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..770474e745 --- /dev/null +++ b/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs @@ -0,0 +1,53 @@ +/* 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/ext/api.js b/browser/components/urlbar/tests/ext/api.js new file mode 100644 index 0000000000..44f4802db1 --- /dev/null +++ b/browser/components/urlbar/tests/ext/api.js @@ -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/. */ + +/* global ExtensionAPI, ExtensionCommon */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + Preferences: "resource://gre/modules/Preferences.jsm", + UrlbarProviderExtension: "resource:///modules/UrlbarProviderExtension.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarView: "resource:///modules/UrlbarView.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.jsm 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..e5b2d7631d --- /dev/null +++ b/browser/components/urlbar/tests/ext/browser/head.js @@ -0,0 +1,251 @@ +/* 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"; + +/* import-globals-from ../../browser/head-common.js */ +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/urlbar/tests/ext/browser/head-common.js", + this +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.jsm", +}); + +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_task(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 {function} background + * This function is serialized and becomes the background script. + * @param {object} 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.fail( + `"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..26b0df5a34 --- /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/unit/data/engine-suggestions.xml b/browser/components/urlbar/tests/unit/data/engine-suggestions.xml new file mode 100644 index 0000000000..2f7a6f7b09 --- /dev/null +++ b/browser/components/urlbar/tests/unit/data/engine-suggestions.xml @@ -0,0 +1,16 @@ +<?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>engine-suggestions.xml</ShortName> +<Url type="application/x-suggestions+json" + method="GET" + template="http://localhost:9000/suggest?{searchTerms}"/> +<Url type="text/html" + method="GET" + template="http://localhost:9000/search" + rel="searchform"> + <Param name="terms" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/browser/components/urlbar/tests/unit/data/engine-tail-suggestions.xml b/browser/components/urlbar/tests/unit/data/engine-tail-suggestions.xml new file mode 100644 index 0000000000..65f208884d --- /dev/null +++ b/browser/components/urlbar/tests/unit/data/engine-tail-suggestions.xml @@ -0,0 +1,14 @@ +<?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>engine-tail-suggestions.xml</ShortName> +<Url type="application/x-suggestions+json" + method="GET" + template="http://localhost:9001/suggest?{searchTerms}"/> +<Url type="text/html" + method="GET" + template="http://localhost:9001/search" + rel="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..04c5e42eb9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/head.js @@ -0,0 +1,946 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +var { + UrlbarMuxer, + UrlbarProvider, + UrlbarQueryContext, + UrlbarUtils, +} = ChromeUtils.import("resource:///modules/UrlbarUtils.jsm"); +XPCOMUtils.defineLazyModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + HttpServer: "resource://testing-common/httpd.js", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + TestUtils: "resource://testing-common/TestUtils.jsm", + UrlbarController: "resource:///modules/UrlbarController.jsm", + UrlbarInput: "resource:///modules/UrlbarInput.jsm", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm", + UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.jsm", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm", + UrlbarResult: "resource:///modules/UrlbarResult.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", + UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm", +}); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +AddonTestUtils.init(this, false); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function initXPCShellDependencies() { + await UrlbarTestUtils.initXPCShellDependencies(); +}); + +/** + * 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). + * + * @return 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 + ) + ); + 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. + * @returns {string} name of the registered provider + */ +function registerBasicTestProvider(results = [], onCancel, type) { + let provider = new TestProvider({ results, onCancel, type }); + UrlbarProvidersManager.registerProvider(provider); + return provider.name; +} + +// Creates an HTTP server for the test. +function makeTestServer(port = -1) { + let httpServer = new HttpServer(); + httpServer.start(port); + registerCleanupFunction(() => httpServer.stop(() => {})); + return httpServer; +} + +/** + * Adds a search engine to the Search Service. + * + * @param {string} basename + * Basename for the engine. + * @param {object} httpServer [optional] HTTP Server to use. + * @returns {Promise} Resolved once the addition is complete. + */ +async function addTestEngine(basename, httpServer = undefined) { + httpServer = httpServer || makeTestServer(); + httpServer.registerDirectory("/", do_get_cwd()); + let dataUrl = + "http://localhost:" + httpServer.identity.primaryPort + "/data/"; + + // Before initializing the search service, set the geo IP url pref to a dummy + // string. When the search service is initialized, it contacts the URI named + // in this pref, causing unnecessary error logs. + let geoPref = "browser.search.geoip.url"; + Services.prefs.setCharPref(geoPref, ""); + registerCleanupFunction(() => Services.prefs.clearUserPref(geoPref)); + + info("Adding engine: " + basename); + return new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic, data) { + let engine = subject.QueryInterface(Ci.nsISearchEngine); + info("Observed " + data + " for " + engine.name); + if (data != "engine-added" || engine.name != basename) { + return; + } + + Services.obs.removeObserver(obs, "browser-search-engine-modified"); + registerCleanupFunction(() => Services.search.removeEngine(engine)); + resolve(engine); + }, "browser-search-engine-modified"); + + info("Adding engine from URL: " + dataUrl + basename); + Services.search.addOpenSearchEngine(dataUrl + basename, null); + }); +} + +/** + * 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(9000); + server.registerPathHandler("/suggest", (req, resp) => { + // URL query params are x-www-form-urlencoded, which converts spaces into + // plus signs, so un-convert any plus signs back to spaces. + let searchStr = decodeURIComponent(req.queryString.replace(/\+/g, " ")); + 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)); + }); + let engine = await addTestEngine("engine-suggestions.xml", server); + 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(9001); + server.registerPathHandler("/suggest", (req, resp) => { + // URL query params are x-www-form-urlencoded, which converts spaces into + // plus signs, so un-convert any plus signs back to spaces. + let searchStr = decodeURIComponent(req.queryString.replace(/\+/g, " ")); + 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); + }); + let engine = await addTestEngine("engine-tail-suggestions.xml", server); + return engine; +} + +/** + * 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); + }); + + Services.search.setDefault(engine); + 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(); +} + +/** + * Returns the frecency of a url. + * + * @param {string} aURI The URI or spec to get frecency for. + * @returns {number} the frecency value. + */ +function frecencyForUrl(aURI) { + let url = aURI; + if (aURI instanceof Ci.nsIURI) { + url = aURI.spec; + } else if (aURI instanceof URL) { + url = aURI.href; + } + let stmt = DBConn().createStatement( + "SELECT frecency FROM moz_places WHERE url_hash = hash(?1) AND url = ?1" + ); + stmt.bindByIndex(0, url); + try { + if (!stmt.executeStep()) { + throw new Error("No result for frecency."); + } + return stmt.getInt32(0); + } finally { + stmt.finalize(); + } +} + +/** + * Creates a UrlbarResult for a bookmark result. + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @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. + * @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 {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 {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 result = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.OMNIBOX, + UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, { + title: [description, UrlbarUtils.HIGHLIGHT.TYPED], + content: [content, UrlbarUtils.HIGHLIGHT.TYPED], + keyword: [keyword, UrlbarUtils.HIGHLIGHT.TYPED], + icon: [UrlbarUtils.ICON.EXTENSION], + }) + ); + result.heuristic = heuristic; + return result; +} + +/** + * Creates a UrlbarResult for a keyword search result. + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @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, + 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 {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 search result. + * @param {UrlbarQueryContext} queryContext + * The context that this result will be displayed in. + * @param {string} [options.suggestion] + * The suggestion offered by the search engine. + * @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. + * @returns {UrlbarResult} + */ +function makeSearchResult( + queryContext, + { + suggestion, + tailPrefix, + tail, + tailOffsetIndex, + engineName, + alias, + uri, + query, + engineIconUri, + providesSearchMode, + providerName, + inPrivateWindow, + isPrivateEngine, + heuristic = 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); + } + } + + let result = new UrlbarResult( + type, + source, + ...UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, payload) + ); + + if (typeof suggestion == "string") { + result.payload.lowerCaseSuggestion = result.payload.suggestion.toLocaleLowerCase(); + } + + 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 {string} options.title + * The page title. + * @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} 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. + * @returns {UrlbarResult} + */ +function makeVisitResult( + queryContext, + { + title, + uri, + iconUri, + providerName, + tags = null, + heuristic = false, + source = UrlbarUtils.RESULT_SOURCE.HISTORY, + } +) { + let payload = { + url: [uri, UrlbarUtils.HIGHLIGHT.TYPED], + title: [title, 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 {UrlbarQueryContext} context + * The context for this query. + * @param {string} [incompleteSearch] + * A search will be fired for this string and then be immediately canceled by + * the query in `context`. + * @param {string} [autofilled] + * The autofilled value in the first result. + * @param {string} [completed] + * The value that would be filled if the autofill result was confirmed. + * Has no effect if `autofilled` is not specified. + * @param {array} matches + * An array of UrlbarResults. + * @param {boolean} [isPrivate] + * Set this to `true` to simulate a search in a private window. + */ +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(); + + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + 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}` + ); + if (expected.providerName) { + Assert.equal( + actual.providerName, + expected.providerName, + `result.providerName at result index ${i}` + ); + } + 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_UrlbarController_integration.js b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js new file mode 100644 index 0000000000..6da8255b5a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarController_integration.js @@ -0,0 +1,104 @@ +/* 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.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +const TEST_URL = "http://example.com"; +const match = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.TAB_SWITCH, + UrlbarUtils.RESULT_SOURCE.TABS, + { url: TEST_URL } +); +let controller; + +/** + * Asserts that the query context has the expected values. + * + * @param {UrlbarQueryContext} 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(async function setup() { + controller = UrlbarTestUtils.newMockController(); +}); + +add_task(async function test_basic_search() { + let providerName = registerBasicTestProvider([match]); + const context = createContext(TEST_URL, { providers: [providerName] }); + + 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 providerName = registerBasicTestProvider( + [match], + providerCanceledDeferred.resolve + ); + const context = createContext(TEST_URL, { providers: [providerName] }); + + 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..d103eb10f5 --- /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..c62df478f1 --- /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 + * @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..b08a5fff2a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarPrefs.js @@ -0,0 +1,40 @@ +/* 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 + ); +}); 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..056110fed9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarQueryContext_restrictSource.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/. */ + +/** + * Test for restrictions set through UrlbarQueryContext.sources. + */ + +add_task(async function setup() { + let engine = await addTestSuggestionsEngine(); + let oldDefaultEngine = await Services.search.getDefault(); + Services.search.setDefault(engine); + registerCleanupFunction(async () => + Services.search.setDefault(oldDefaultEngine) + ); +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "unifiedComplete", + "@mozilla.org/autocomplete/search;1?name=unifiedcomplete", + "nsIAutoCompleteSearch" +); + +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/"); + + 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 != "engine-suggestions.xml"), + "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 != "engine-suggestions.xml"), + "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"); + let aliasEngine = await Services.search.addEngineWithDetails("Test", { + alias: "match", + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async function() { + await Services.search.removeEngine(aliasEngine); + }); + results = await get_results({ + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + searchString: "match this", + }); + Assert.ok( + !results.some(r => r.payload.engine != "engine-suggestions.xml"), + "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.jsm b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.jsm new file mode 100644 index 0000000000..92b2cfbb4b --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarSearchUtils.jsm @@ -0,0 +1,292 @@ +/* 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.import( + "resource:///modules/UrlbarSearchUtils.jsm" +); + +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.resetToOriginalDefaultEngine(); +}); + +add_task(async function search_engine_match() { + let engine = await Services.search.getDefault(); + let domain = engine.getResultDomain(); + 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.getResultDomain(); + 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].getResultDomain() != domain + ); + engine.hidden = false; + await TestUtils.waitForCondition( + async () => (await UrlbarSearchUtils.enginesForDomainPrefix(token)).length + ); + let matchedEngine2 = ( + await UrlbarSearchUtils.enginesForDomainPrefix(token) + )[0]; + Assert.ok(matchedEngine2); +}); + +add_task(async function onlyEnabled_option_nomatch() { + let engine = await Services.search.getDefault(); + let domain = engine.getResultDomain(); + let token = domain.substr(0, 1); + Services.prefs.setCharPref("browser.search.hiddenOneOffs", engine.name); + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.ok( + !matchedEngines.length || matchedEngines[0].getResultDomain() != domain + ); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix(token, { + onlyEnabled: true, + }); + Assert.ok( + matchedEngines.length && matchedEngines[0].getResultDomain() == domain + ); +}); + +add_task(async function add_search_engine_match() { + let promiseTopic = promiseSearchTopic("engine-added"); + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("bacon")).length + ); + await Promise.all([ + Services.search.addEngineWithDetails("bacon", { + alias: "pork", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.moz/?search={searchTerms}", + }), + promiseTopic, + ]); + await promiseTopic; + let matchedEngine = ( + await UrlbarSearchUtils.enginesForDomainPrefix("bacon") + )[0]; + Assert.ok(matchedEngine); + Assert.equal(matchedEngine.searchForm, "http://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, "http://www.bacon.moz"); + Assert.equal(matchedEngine.name, "bacon"); + Assert.equal(matchedEngine.iconURI, null); +}); + +add_task(async function match_multiple_search_engines() { + let promiseTopic = promiseSearchTopic("engine-added"); + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("baseball")).length + ); + await Promise.all([ + Services.search.addEngineWithDetails("baseball", { + description: "Search Baseball", + method: "GET", + template: "http://www.baseball.moz/?search={searchTerms}", + }), + promiseTopic, + ]); + await promiseTopic; + let matchedEngines = await UrlbarSearchUtils.enginesForDomainPrefix("ba"); + Assert.equal( + matchedEngines.length, + 2, + "enginesForDomainPrefix returned two engines." + ); + Assert.equal(matchedEngines[0].searchForm, "http://www.bacon.moz"); + Assert.equal(matchedEngines[0].name, "bacon"); + Assert.equal(matchedEngines[1].searchForm, "http://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() { + let promiseTopic = promiseSearchTopic("engine-added"); + Assert.equal( + 0, + (await UrlbarSearchUtils.enginesForDomainPrefix("patch")).length + ); + await Promise.all([ + Services.search.addEngineWithDetails("patch", { + alias: "PR", + description: "Search Patch", + method: "GET", + template: "http://www.patch.moz/?search={searchTerms}", + }), + promiseTopic, + ]); + // 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 engine = Services.search.getEngineByName("bacon"); + let promiseTopic = promiseSearchTopic("engine-removed"); + await Promise.all([Services.search.removeEngine(engine), 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 engine = await Services.search.addEngineWithDetails("TestEngine2", { + template: "http://example.com", + }); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await Services.search.removeEngine(engine); + + engine = await Services.search.addEngineWithDetails("TestEngine", { + template: "http://www.subdomain.othersubdomain.example.com", + }); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "example"); + await Services.search.removeEngine(engine); + + // We let engines with URL ending in .test through even though its not a valid + // TLD. + engine = await Services.search.addEngineWithDetails("TestMalformed", { + template: `http://mochi.test/?search={searchTerms}`, + }); + Assert.equal(UrlbarSearchUtils.getRootDomainFromEngine(engine), "mochi"); + await Services.search.removeEngine(engine); + + // We return the domain for engines with a malformed URL. + engine = await Services.search.addEngineWithDetails("TestMalformed", { + template: `http://subdomain.foobar/?search={searchTerms}`, + }); + Assert.equal( + UrlbarSearchUtils.getRootDomainFromEngine(engine), + "subdomain.foobar" + ); + await Services.search.removeEngine(engine); +}); + +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..d2d30e1516 --- /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.import( + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +const { PlacesUIUtils } = ChromeUtils.import( + "resource:///modules/PlacesUIUtils.jsm" +); + +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..c363b4b6ec --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_UrlbarUtils_getShortcutOrURIAndPostData.js @@ -0,0 +1,257 @@ +/* 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"; + +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", "http://bmget/search=%s", null, "foo"), + new keywordResult("http://bmget/search=foo", null), + ], + + [ + new bmKeywordData("bmpost", "http://bmpost/", "search=%s", "foo2"), + new keywordResult("http://bmpost/", "search=foo2"), + ], + + [ + new bmKeywordData( + "bmpostget", + "http://bmpostget/search1=%s", + "search2=%s", + "foo3" + ), + new keywordResult("http://bmpostget/search1=foo3", "search2=foo3"), + ], + + [ + new bmKeywordData("bmget-nosearch", "http://bmget-nosearch/", null, ""), + new keywordResult("http://bmget-nosearch/", null), + ], + + [ + new searchKeywordData( + "searchget", + "http://searchget/?search={searchTerms}", + null, + "foo4" + ), + new keywordResult("http://searchget/?search=foo4", null, true), + ], + + [ + new searchKeywordData( + "searchpost", + "http://searchpost/", + "search={searchTerms}", + "foo5" + ), + new keywordResult("http://searchpost/", "search=foo5", true), + ], + + [ + new searchKeywordData( + "searchpostget", + "http://searchpostget/?search1={searchTerms}", + "search2={searchTerms}", + "foo6" + ), + new keywordResult( + "http://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", "http://bmget-noparam/", null, "foo7"), + new keywordResult(null, null, true), + ], + [ + new bmKeywordData( + "bmpost-noparam", + "http://bmpost-noparam/", + "not_a=param", + "foo8" + ), + new keywordResult(null, null, true), + ], + + // Test escaping (%s = escaped, %S = raw) + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "http://bmget/?esc=%s&raw=%S", + null, + "fo\xE9" + ), + new keywordResult("http://bmget/?esc=fo%C3%A9&raw=fo\xE9", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "http://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "fo\xE9" + ), + new keywordResult("http://bmget/?esc=fo%E9&raw=fo\xE9", null), + ], + + // Bug 359809: Test escaping +, /, and @ + // UTF-8 default + [ + new bmKeywordData( + "bmget-escaping", + "http://bmget/?esc=%s&raw=%S", + null, + "+/@" + ), + new keywordResult("http://bmget/?esc=%2B%2F%40&raw=+/@", null), + ], + // Explicitly-defined ISO-8859-1 + [ + new bmKeywordData( + "bmget-escaping2", + "http://bmget/?esc=%s&raw=%S&mozcharset=ISO-8859-1", + null, + "+/@" + ), + new keywordResult("http://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: "http://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; +var gAddedEngines = []; + +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) { + let addedEngine = await Services.search.addEngineWithDetails( + data.keyword, + { + alias: data.keyword, + method: data.method, + template: data.uri.spec, + searchPostParams: data.postData, + } + ); + gAddedEngines.push(addedEngine); + } + } +} + +async function cleanupKeywords() { + await PlacesUtils.bookmarks.remove(folder); + for (let engine of gAddedEngines) { + await Services.search.removeEngine(engine); + } + gAddedEngines = []; +} 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_autofill_about_urls.js b/browser/components/urlbar/tests/unit/test_autofill_about_urls.js new file mode 100644 index 0000000000..4a15aa5e9b --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_about_urls.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/. */ + +"use strict"; + +const ENGINE_NAME = "engine-suggestions.xml"; + +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", + iconUri: "", + providerName: "UnifiedComplete", + }), + ], + }); +}); + +// "about:" should *not* match anything +add_task(async function aboutColonHasNoMatch() { + let context = createContext("about:", { isPrivate: false }); + await check_results({ + context, + search: "about:", + matches: [ + makeSearchResult(context, { + engineName: ENGINE_NAME, + providerName: "HeuristicFallback", + heuristic: true, + }), + ], + }); +}); 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..39483e993a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_bookmarked.js @@ -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/. */ + +// 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.setBoolPref("browser.urlbar.suggest.searches", 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: `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}/`, + title: `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}/`, + title: `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}/`, + title: `www.${host}`, + heuristic: true, + }), + ], + }); +}); 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..662cf420b8 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_functional.js @@ -0,0 +1,112 @@ +/* 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.setBoolPref("browser.urlbar.suggest.searches", 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: "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: "bookmark1.mozilla.org", + 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: "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: "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..af9685286f --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_origins.js @@ -0,0 +1,638 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; +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(); + +// "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: `${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: `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: `${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: `${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: `${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: `${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/`, + title: `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/`, + title: `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/", + title: "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: "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: str.replace(/\/$/, ""), // strip trailing slash + 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: "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 = frecencyForUrl("http://example.com/"); + let httpsFrec = frecencyForUrl("https://example.com/"); + let otherFrec = frecencyForUrl("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: "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 = frecencyForUrl("http://example.com/"); + let httpsFrec = frecencyForUrl("https://example.com/"); + let otherFrec = frecencyForUrl("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: "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: 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: 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: 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, + title: "example.com", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + await cleanup(); +}); + +// This is similar to suggestHistoryFalse_bookmark_prefix_0 in +// autofill_tasks.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}/`, + title: `${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}/`, + title: `${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}/`, + title: `${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, + title: "example.com", + heuristic: true, + }), + makeBookmarkResult(context, { + uri: bookmarkedURL, + title: "A bookmark", + }), + ], + }); + + 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..5ad440596b --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_originsAndQueries.js @@ -0,0 +1,2408 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const UNIFIEDCOMPLETE_PROVIDERNAME = "UnifiedComplete"; + +/** + * 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(); + +let path; +let search; +let searchCase; +let title; +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"; + title = "example.com/foo"; + url = host + path; + await callback(); + + info(`Running subtest with origins enabled: ${callback.name}`); + origins = true; + path = "/"; + search = "ex"; + searchCase = "EX"; + title = "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, + 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, + 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: "www." + title, + 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: "www." + title, + 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 + "/", + title: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + title: "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, + 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, + 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: "www." + title, + 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: "www." + title, + 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, + title: 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, + title: 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: UNIFIEDCOMPLETE_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: "https://" + title, + 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: "https://www." + title, + 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 + "/", + title: "http://www." + search + "/", + displayUrl: "http://www." + search, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://www." + search, + title: "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: "https://" + title, + 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: "https://www." + title, + 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, + title: 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, + title: 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: UNIFIEDCOMPLETE_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: "https://" + title, + 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, + 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: "https://" + title, + 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, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: UNIFIEDCOMPLETE_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: "https://www." + title, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: UNIFIEDCOMPLETE_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, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: UNIFIEDCOMPLETE_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: "https://" + title, + 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: "https://" + title, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: UNIFIEDCOMPLETE_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 UnifiedComplete 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: ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "https://" + url, + title: "test visit for https://" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + } else { + await check_results({ + context, + autofilled: url, + completed: "https://" + url, + matches: [ + makeVisitResult(context, { + uri: "https://" + url, + title: "https://" + title, + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://not-" + url, + title: "test visit for https://not-" + url, + providerName: UNIFIEDCOMPLETE_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: "https://" + title, + 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: 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, + title: "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, + }); + + // 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.fieldInDB( + "http://" + url, + "frecency" + ); + 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, + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://not-" + url, + title: "test visit for http://not-" + url, + providerName: UNIFIEDCOMPLETE_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, + }); + + // 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.fieldInDB( + "http://" + url, + "frecency" + ); + 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, + heuristic: true, + }), + ], + }); + + await cleanup(); +}); + +// Bookmark a page and then clear history. The bookmarked origin/URL should +// be autofilled even though its frecency is <= 0 since the autofill threshold +// is 0. +add_autofill_task(async function zeroThreshold() { + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + + await PlacesUtils.history.clear(); + + // Make sure the place's frecency is <= 0. (It will be reset to -1 on the + // history.clear() above, and then on idle it will be reset to 0. xpcshell + // tests disable the idle service, so in practice it should always be -1, + // but in order to avoid possible intermittent failures in the future, don't + // assume that.) + let placeFrecency = await PlacesTestUtils.fieldInDB( + "http://" + url, + "frecency" + ); + Assert.ok(placeFrecency <= 0); + + // 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, + 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, + 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: 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, + title: "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, + 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: 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, + title: "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, + }); + + // 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, + 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, + }); + 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: ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://" + search, + title: "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, + }); + + // 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, + }); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title, + 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, + }); + 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, + title: 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, + }); + 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, + title: 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, + }); + 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, + title: 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, + 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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ]; + if (origins) { + matches.unshift( + makeSearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }) + ); + } else { + matches.unshift( + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: prefixedUrl, + title: 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, + 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, + title: 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: UNIFIEDCOMPLETE_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, + title: 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: UNIFIEDCOMPLETE_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, + title: 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: UNIFIEDCOMPLETE_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, + }); + let context = createContext(search, { isPrivate: false }); + await check_results({ + context, + autofilled: url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title, + 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: 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, + title: "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, + }); + let context = createContext("http://" + search, { isPrivate: false }); + await check_results({ + context, + autofilled: "http://" + url, + completed: "http://" + url, + matches: [ + makeVisitResult(context, { + uri: "http://" + url, + title, + 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, + title: 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, + }); + 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, + title: 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, + }); + 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, + title: 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, + }); + 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, + title: 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, + }); + 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, + 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, + }); + 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, + 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, + }); + 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, + title: 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, + }); + 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, + title: 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, + }); + 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, + title: 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: ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + Services.prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + context = createContext(search, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + providerName: HEURISTIC_FALLBACK_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://some-other-" + url, + title: "test visit for http://some-other-" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: UNIFIEDCOMPLETE_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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "test visit for http://" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://" + url, + }); + 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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://" + url, + title: "A bookmark", + providerName: UNIFIEDCOMPLETE_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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "test visit for ftp://" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://" + url, + }); + 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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://" + url, + title: "A bookmark", + providerName: UNIFIEDCOMPLETE_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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "test visit for http://non-matching-" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "http://non-matching-" + url, + }); + 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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://non-matching-" + url, + title: "A bookmark", + providerName: UNIFIEDCOMPLETE_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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "test visit for ftp://non-matching-" + url, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + // Now bookmark it and set suggest.bookmark to false. + await PlacesTestUtils.addBookmarkWithDetails({ + uri: "ftp://non-matching-" + url, + }); + 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}/`, + title: `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: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "ftp://non-matching-" + url, + title: "A bookmark", + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + await cleanup(); + } +); 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..ef3366d8db --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_prefix_fallback.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/. */ + +// 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.setBoolPref("browser.urlbar.suggest.searches", 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}/`, + title: `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}/`, + title: `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..9f87947770 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_search_engine_aliases.js @@ -0,0 +1,90 @@ +/* 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 Services.search.addEngineWithDetails(TEST_ENGINE_NAME, { + alias: TEST_ENGINE_ALIAS, + template: "http://example.com/?search={searchTerms}", + }); + registerCleanupFunction(async () => { + let engine = Services.search.getEngineByName(TEST_ENGINE_NAME); + Assert.ok(engine); + await Services.search.removeEngine(engine); + }); +}); + +// 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..63af7115f2 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_search_engines.js @@ -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/. */ + +// 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 UnifiedComplete.js from normal moz_places autofill, which is tested +// in test_autofill_origins.js and test_autofill_urls.js. + +"use strict"; + +const ENGINE_NAME = "TestEngine"; + +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" + ); + }); + + let schemes = ["http", "https"]; + for (let i = 0; i < schemes.length; i++) { + let scheme = schemes[i]; + let engine = await Services.search.addEngineWithDetails(ENGINE_NAME, { + method: "GET", + template: scheme + "://www.example.com/", + searchGetParams: "q={searchTerms}", + }); + + 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 = schemes[(i + 1) % schemes.length]; + 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/", + title: 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/", + title: 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/", + title: "http://example/", + iconUri: "page-icon:http://example/", + heuristic: true, + }), + ], + }); + + await Services.search.removeEngine(engine); + } +}); 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..d0424a4b8d --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_autofill_urls.js @@ -0,0 +1,218 @@ +/* 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 UNIFIEDCOMPLETE_PROVIDERNAME = "UnifiedComplete"; + +// "example.com/foo/" should match http://example.com/foo/. +testEngine_setup(); + +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: "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: "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", + title: "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/", + title: "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: UNIFIEDCOMPLETE_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: "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(); + + Services.prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + Services.prefs.clearUserPref("browser.urlbar.suggest.history"); + await cleanupPlaces(); +}); + +async function testCaseInsensitive() { + 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: "example.com/foo", + heuristic: true, + }), + ], + }); + } +} 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..a6de9f3a3a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_avoid_middle_complete.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/. */ + +const ENGINE_NAME = "engine-suggestions.xml"; + +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: 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: 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); + let engine = await Services.search.addEngineWithDetails("CakeSearch", { + method: "GET", + template: "http://cake.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + 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); + let engine = await Services.search.addEngineWithDetails("CupcakeSearch", { + method: "GET", + template: "http://cupcake.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + 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: 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); + let engine = await Services.search.addEngineWithDetails("BaconSearch", { + method: "GET", + template: "http://bacon.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + 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: ENGINE_NAME, + query: "ba ", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_www_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + let engine = await Services.search.addEngineWithDetails("HamSearch", { + method: "GET", + template: "http://ham.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + 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/", + title: "http://www.ham/", + displayUrl: "http://www.ham", + heuristic: true, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + query: "www.ham", + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_different_scheme_noautofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + let engine = await Services.search.addEngineWithDetails("PieSearch", { + method: "GET", + template: "https://pie.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + 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/", + title: "http://pie/", + iconUri: "", + heuristic: true, + }), + ], + }); + + await cleanupPlaces(); +}); + +add_task(async function test_searchEngine_matching_prefix_autofill() { + Services.prefs.setBoolPref("browser.urlbar.autoFill.searchEngines", true); + let engine = await Services.search.addEngineWithDetails("BeanSearch", { + method: "GET", + template: "http://www.bean.search/", + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => Services.search.removeEngine(engine)); + + info("Should autoFill search engine if search string has matching prefix."); + let context = createContext("http://www.be", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://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("http://be", { isPrivate: false }); + await check_results({ + context, + autofilled: "http://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/", + title: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: "UnifiedComplete", + }), + ], + }); + + 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..b9e3227874 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_avoid_stripping_to_empty_tokens.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 ENGINE_NAME = "engine-suggestions.xml"; + +testEngine_setup(); + +add_task(async function test_protocol_trimming() { + for (let prot of ["http", "https", "ftp"]) { + 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/", + title: + 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/", + title: + 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()}/`, + title: `${input.trim()}/`, + iconUri: "", + heuristic: true, + providerName: "HeuristicFallback", + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "UnifiedComplete", + }), + ], + }); + + 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: ENGINE_NAME, + query: input, + heuristic: true, + }), + makeVisitResult(context, { + uri: visit.uri.spec, + title: visit.title, + providerName: "UnifiedComplete", + }), + ], + }); + } + + await cleanupPlaces(); + } +}); 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..89e58c45a9 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_casing.js @@ -0,0 +1,356 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; +const AUTOFILL_PROVIDERNAME = "Autofill"; +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const UNIFIEDCOMPLETE_PROVIDERNAME = "UnifiedComplete"; + +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/", + title: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/test/", + title: "test visit for http://mozilla.org/test/", + providerName: UNIFIEDCOMPLETE_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: "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: "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: "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: "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/", + title: "mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://mozilla.org/Test/", + title: "test visit for http://mozilla.org/Test/", + providerName: UNIFIEDCOMPLETE_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/", + title: "www.mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://www.mozilla.org/Test/", + title: "test visit for http://www.mozilla.org/Test/", + providerName: UNIFIEDCOMPLETE_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: "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: "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: "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: "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: 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: 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: 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: 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: 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..58f223fbc4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dedupe_prefix.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// 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", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + { + uri: "https://www.example.com/foo/", + title: "Example Page", + }, + ]); + + // We should get https://www. as the heuristic result but https:// in the + // results since the latter's prefix is a higher priority. + 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: "https://www.example.com/foo/", + 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", + }, + ]); + } + + 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: "www.example.com/foo/", + 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", + }, + ]); + } + + 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: "https://example.com/foo/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "https://www.example.com/foo/", + title: "Example Page", + }), + ], + }); + + await cleanupPlaces(); +}); diff --git a/browser/components/urlbar/tests/unit/test_dupe_urls.js b/browser/components/urlbar/tests/unit/test_dupe_urls.js new file mode 100644 index 0000000000..9707233279 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_dupe_urls.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Ensure inline autocomplete doesn't return zero frecency pages. + +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_dupe_urls() { + info("Searching for urls with dupes should only show one"); + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("http://mozilla.org/"), + }, + { + uri: Services.io.newURI("http://mozilla.org/?"), + } + ); + let context = createContext("moz", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "http://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "http://mozilla.org/", + title: "mozilla.org", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_dupe_secure_urls() { + await PlacesTestUtils.addVisits( + { + uri: Services.io.newURI("https://example.org/"), + }, + { + uri: Services.io.newURI("https://example.org/?"), + } + ); + let context = createContext("exam", { isPrivate: false }); + await check_results({ + context, + autofilled: "example.org/", + completed: "https://example.org/", + matches: [ + makeVisitResult(context, { + uri: "https://example.org/", + title: "https://example.org", + heuristic: true, + }), + ], + }); + 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..5260683ddd --- /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, + matches: [ + makeVisitResult(context, { + uri: url, + title: url, + heuristic: true, + }), + ], + autofilled: url, + completed: url, + }); + await cleanupPlaces(); +}); 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..8806fe0601 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_heuristic_cancel.js @@ -0,0 +1,136 @@ +/* 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.import("resource://gre/modules/Timer.jsm"); + +/** + * 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 alterts 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); +}); + +add_task(async function() { + 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 UnifiedComplete 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) { + console.trace(`finished query. context: ${JSON.stringify(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); +}); 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..04984662d3 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_keywords.js @@ -0,0 +1,207 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; + +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/", + title: "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/", + 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/", + title: "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/", + title: "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/", + 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: "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/", + keyword: "moz", + heuristic: true, + }), + ], + }); + + // 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: "mozilla.com", + 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_muxer.js b/browser/components/urlbar/tests/unit/test_muxer.js new file mode 100644 index 0000000000..b714ee50c1 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_muxer.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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 providerName = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [providerName] }); + let controller = UrlbarTestUtils.newMockController(); + /** + * A test muxer. + */ + class TestMuxer extends UrlbarMuxer { + get name() { + return "TestMuxer"; + } + sort(queryContext) { + queryContext.results.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 providerName = registerBasicTestProvider(matches); + let context = createContext(undefined, { + providers: [providerName], + }); + 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 provider1Name = registerBasicTestProvider(matches1); + let provider2Name = registerBasicTestProvider(matches2); + + let context = createContext(undefined, { + providers: [provider1Name, provider2Name], + }); + 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 providerName = registerBasicTestProvider(matches); + + let context = createContext(undefined, { + providers: [providerName], + }); + 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"); +}); 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..ce323a5027 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerHeuristicFallback.js @@ -0,0 +1,613 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +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 "]; + +add_task(async function setup() { + // Install a test engine so we're sure of ENGINE_NAME. + let engine = await addTestSuggestionsEngine(); + + // Install the test engine. + let oldDefaultEngine = await Services.search.getDefault(); + registerCleanupFunction(async () => { + Services.search.setDefault(oldDefaultEngine); + Services.prefs.clearUserPref(SUGGEST_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref("keyword.enabled"); + }); + Services.search.setDefault(engine); + Services.prefs.setBoolPref(SUGGEST_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}/`, + title: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: 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}/`, + title: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: 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}/`, + title: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: 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}/`, + title: `${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}/`, + title: `${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}/`, + title: `${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, + title: 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}/`, + title: `${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}`, + title: `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: 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: 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}/`, + title: `http://${query}/`, + heuristic: true, + }), + makeSearchResult(context, { + engineName: 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}`, + title: `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}`, + title: `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}/`, + title: `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}/`, + title: `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}/`, + title: `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, + title: 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: 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: 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}/`, + title: `${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}/`, + title: `${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}/`, + title: `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: 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: 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, + title: 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, + title: query, + heuristic: true, + }), + ], + }); + + info("protocol with an extra slash"); + query = "http:///"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: ENGINE_NAME, + heuristic: true, + }), + ], + }); + + info("change default engine"); + let originalTestEngine = Services.search.getEngineByName(ENGINE_NAME); + let engine2 = await Services.search.addEngineWithDetails("AliasEngine", { + alias: "alias", + method: "GET", + template: "http://example.com/?q={searchTerms}", + }); + Assert.notEqual( + Services.search.defaultEngine, + engine2, + "New engine shouldn't be the current engine yet" + ); + await Services.search.setDefault(engine2); + query = "toronto"; + context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "AliasEngine", + heuristic: true, + }), + ], + }); + await Services.search.setDefault(originalTestEngine); + + 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 = 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 = 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: ENGINE_NAME, + }), + ], + }); + } + } + + await Services.search.removeEngine(engine2); +}); + +/** + * 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_providerOmnibox.js b/browser/components/urlbar/tests/unit/test_providerOmnibox.js new file mode 100644 index 0000000000..09fdfdec05 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerOmnibox.js @@ -0,0 +1,818 @@ +/* -*- 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.import( + "resource://gre/modules/ExtensionSearchHandler.jsm" +); + +let controller = Cc["@mozilla.org/autocomplete/controller;1"].getService( + Ci.nsIAutoCompleteController +); + +const ENGINE_NAME = "engine-suggestions.xml"; +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_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" }, + ]); + // 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", + }), + ], + }); + + 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); + + 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: ENGINE_NAME, + alias: keyword, + suggestion: "unmatched", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: ENGINE_NAME, + alias: keyword, + suggestion: "unmatched foo", + }), + makeSearchResult(context, { + query: "unmatched", + engineName: ENGINE_NAME, + alias: keyword, + suggestion: "unmatched bar", + }), + ], + }); + + Services.search.setDefault(oldDefaultEngine); + 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..898bb6885e --- /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); + UrlbarProviderOpenTabs.registerOpenTab(url, userContextId); + Assert.equal( + UrlbarProviderOpenTabs.openTabs.get(userContextId).length, + 2, + "Found all the expected tabs" + ); + UrlbarProviderOpenTabs.unregisterOpenTab(url, userContextId); + 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_providerTabToSearch.js b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js new file mode 100644 index 0000000000..ca08493579 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch.js @@ -0,0 +1,477 @@ +/* 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 + ); + testEngine = await Services.search.addEngineWithDetails("Test", { + template: "https://example.com/?search={searchTerms}", + }); + + registerCleanupFunction(async () => { + await Services.search.removeEngine(testEngine); + 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: "https://example.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + testEngine.getResultDomain() + ), + 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: "https://example.com", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + Services.prefs.clearUserPref("browser.urlbar.suggest.engines"); + + await cleanupPlaces(); +}); + +// Tests that tab-to-search results aren't shown when the typed string matches +// an engine domain but 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", + }), + ], + }); +}); + +// 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: "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: "https://www.example.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + testEngine.getResultDomain() + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + // The engine has www., the history result does not. + await PlacesTestUtils.addVisits(["https://foo.bar/"]); + let wwwTestEngine = await Services.search.addEngineWithDetails("TestWww", { + template: "https://www.foo.bar/?search={searchTerms}", + }); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + autofilled: "foo.bar/", + completed: "https://foo.bar/", + matches: [ + makeVisitResult(context, { + uri: "https://foo.bar/", + title: "https://foo.bar", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.getResultDomain() + ), + 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: "https://www.foo.bar", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: wwwTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + wwwTestEngine.getResultDomain() + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + + await Services.search.removeEngine(wwwTestEngine); +}); + +// 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 fooBarTestEngine = await Services.search.addEngineWithDetails( + "TestFooBar", + { template: "https://foobar.com/?search={searchTerms}" } + ); + let fooTestEngine = await Services.search.addEngineWithDetails("TestFoo", { + template: "https://foo.com/?search={searchTerms}", + }); + + // 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: "https://foo.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooTestEngine.getResultDomain() + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + makeVisitResult(context, { + uri: "https://foobar.com/", + title: "test visit for https://foobar.com/", + providerName: "UnifiedComplete", + }), + ], + }); + + // 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: "https://foobar.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: fooBarTestEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + fooBarTestEngine.getResultDomain() + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + + await cleanupPlaces(); + await Services.search.removeEngine(fooTestEngine); + await Services.search.removeEngine(fooBarTestEngine); +}); + +add_task(async function multipleEnginesForHostname() { + info( + "In case of multiple engines only one tab-to-search result should be returned" + ); + let mapsEngine = await Services.search.addEngineWithDetails("TestMaps", { + template: "https://example.com/maps/?search={searchTerms}", + }); + 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: "https://example.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + testEngine.getResultDomain() + ), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + ], + }); + await cleanupPlaces(); + await Services.search.removeEngine(mapsEngine); +}); + +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: "https://example.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: testEngine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost( + testEngine.getResultDomain() + ), + 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 engine = await Services.search.addEngineWithDetails("MyTest", { + template: "https://test.mytest.it/?search={searchTerms}", + }); + 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_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.getResultDomain()), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + makeVisitResult(context, { + uri: "https://test.mytest.it/", + title: "test visit for https://test.mytest.it/", + providerName: "UnifiedComplete", + }), + ], + }); + await cleanupPlaces(); + await Services.search.removeEngine(engine); +}); + +add_task(async function test_publicSuffixIsHost() { + info("Tab-to-search results does not appear in case we autofill a suffix."); + let suffixEngine = await Services.search.addEngineWithDetails("SuffixTest", { + template: "https://somesuffix.com.mx/?search={searchTerms}", + }); + // 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: "https://com.mx", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + await cleanupPlaces(); + await Services.search.removeEngine(suffixEngine); +}); + +add_task(async function test_disabledEngine() { + info("Tab-to-search results does not appear for a Pref-disabled engine."); + let engine = await Services.search.addEngineWithDetails("Disabled", { + template: "https://disabled.com/?search={searchTerms}", + }); + 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: "https://disabled.com", + heuristic: true, + providerName: "Autofill", + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.getResultDomain()), + 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: "https://disabled.com", + heuristic: true, + providerName: "Autofill", + }), + ], + }); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + + await cleanupPlaces(); + await Services.search.removeEngine(engine); +}); 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..4644117b07 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerTabToSearch_partialHost.js @@ -0,0 +1,150 @@ +/* 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"; + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.urlbar.suggest.searches", 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.search.separatePrivateDefault.ui.enabled" + ); + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); + }); + + let url = "https://en.example.com/"; + let engine = await Services.search.addEngineWithDetails("TestEngine", { + method: "GET", + template: url, + searchGetParams: "q={searchTerms}", + }); + let defaultEngine = await Services.search.getDefault(); + await Services.search.setDefault(engine); + registerCleanupFunction(async () => { + await Services.search.setDefault(defaultEngine); + await Services.search.removeEngine(engine); + }); + // 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: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + 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/"; + let engine2 = await Services.search.addEngineWithDetails("TestEngine2", { + method: "GET", + template: url2, + searchGetParams: "q={searchTerms}", + }); + registerCleanupFunction(async () => { + await Services.search.removeEngine(engine2); + }); + // 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_INVERTED, + 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("Restricting to history should not autofill our bookmark"); + let 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"); +}); diff --git a/browser/components/urlbar/tests/unit/test_providerUnifiedComplete.js b/browser/components/urlbar/tests/unit/test_providerUnifiedComplete.js new file mode 100644 index 0000000000..3b78564484 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerUnifiedComplete.js @@ -0,0 +1,242 @@ +/* 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 UnifiedComplete 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_unifiedComplete() { + 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); + + 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); +}); + +add_task(async function test_bookmarkBehaviorDisabled_tagged() { + Services.prefs.setBoolPref(SUGGEST_PREF, false); + Services.prefs.setBoolPref(SUGGEST_ENABLED_PREF, false); + + // Disable the bookmark behavior in UnifiedComplete. + 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 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 in UnifiedComplete. + 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 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 in UnifiedComplete. + 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 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_providerUnifiedComplete_duplicate_entries.js b/browser/components/urlbar/tests/unit/test_providerUnifiedComplete_duplicate_entries.js new file mode 100644 index 0000000000..7533921fc6 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providerUnifiedComplete_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_providersManager.js b/browser/components/urlbar/tests/unit/test_providersManager.js new file mode 100644 index 0000000000..57598448ea --- /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 providerName = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [providerName] }); + 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..206dd98896 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_providersManager_filtering.js @@ -0,0 +1,405 @@ +/* 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 providerName = registerBasicTestProvider([match]); + let context = createContext(undefined, { providers: [providerName] }); + 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: providerName }); +}); + +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 providerName = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [providerName] }); + 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({ name: providerName }); +}); + +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 providerName = registerBasicTestProvider(matches); + let context = createContext(`foo ${UrlbarTokenizer.RESTRICT.OPENPAGE}`, { + providers: [providerName], + }); + 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({ name: providerName }); +}); + +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 providerName = registerBasicTestProvider([match, jsMatch]); + let context = createContext(undefined, { providers: [providerName] }); + 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: [providerName], + }); + 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: [providerName] }); + 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({ name: providerName }); +}); + +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 providerName = 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); + } + } + } + UrlbarProvidersManager.registerProvider(new NoInvokeProvider()); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.TABS], + providers: [providerName, "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({ name: providerName }); + UrlbarProvidersManager.unregisterProvider({ name: "BadProvider" }); +}); + +add_task(async function test_filter_queryContext() { + let providerName = 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"); + } + } + UrlbarProvidersManager.registerProvider(new NoInvokeProvider()); + + let context = createContext(undefined, { + providers: [providerName], + }); + let controller = UrlbarTestUtils.newMockController(); + + await controller.startQuery(context, controller); + UrlbarProvidersManager.unregisterProvider({ name: providerName }); + UrlbarProvidersManager.unregisterProvider({ name: "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 providerName = registerBasicTestProvider( + matches, + undefined, + UrlbarUtils.PROVIDER_TYPE.HEURISTIC + ); + + let context = createContext(undefined, { + sources: [UrlbarUtils.RESULT_SOURCE.SEARCH], + providers: [providerName], + }); + 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({ name: providerName }); +}); + +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..c3f6e5c2e3 --- /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 providerName = registerBasicTestProvider(matches); + let context = createContext(undefined, { providers: [providerName] }); + 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..4af1b26112 --- /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"; + +XPCOMUtils.defineLazyModuleGetters(this, { + QueryScorer: "resource:///modules/UrlbarProviderInterventions.jsm", +}); + +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..464cae1064 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_query_url.js @@ -0,0 +1,123 @@ +/* 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 ENGINE_NAME = "engine-suggestions.xml"; +const HEURISTIC_FALLBACK_PROVIDERNAME = "HeuristicFallback"; +const UNIFIEDCOMPLETE_PROVIDERNAME = "UnifiedComplete"; + +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/", + title: "file.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: UNIFIEDCOMPLETE_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/", + title: "file.org/", + heuristic: true, + }), + makeVisitResult(context, { + uri: "http://file.org/test/", + title: "test visit for http://file.org/test/", + providerName: UNIFIEDCOMPLETE_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: "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: ENGINE_NAME, + heuristic: true, + }), + makeVisitResult(context, { + uri: "file:///c:/test.html", + title: "test visit for file:///c:/test.html", + iconUri: UrlbarUtils.ICON.DEFAULT, + providerName: UNIFIEDCOMPLETE_PROVIDERNAME, + }), + ], + }); + await cleanupPlaces(); +}); 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..73c455e9c4 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_engine_host.js @@ -0,0 +1,97 @@ +/* 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 Services.search.addEngineWithDetails("MySearchEngine", { + method: "GET", + template: "http://my.search.com/", + }); + engine = Services.search.getEngineByName("MySearchEngine"); + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.urlbar.autoFill.searchEngines"); + Services.prefs.clearUserPref("browser.urlbar.suggest.searches"); + Services.search.removeEngine(engine); + }); + + // 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 PlacesTestUtils.promiseAsyncUpdates(); + ok( + frecencyForUrl(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/", + title: "my.search.com", + heuristic: true, + }), + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS_INVERTED, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.getResultDomain()), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + }), + makeVisitResult(context, { + uri: "http://my.search.com/samplepage/", + title: "test visit for http://my.search.com/samplepage/", + providerName: "UnifiedComplete", + }), + ], + }); + + await cleanupPlaces(); + Services.prefs.clearUserPref( + "browser.urlbar.tabToSearch.onboard.interactionsLeft" + ); +}); 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..0b1180959a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions.js @@ -0,0 +1,1695 @@ +/* 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 { FormHistory } = ChromeUtils.import( + "resource://gre/modules/FormHistory.jsm" +); + +const ENGINE_NAME = "engine-suggestions.xml"; +// This is fixed to match the port number in engine-suggestions.xml. +const SERVER_PORT = 9000; +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 MAX_RICH_RESULTS_PREF = "browser.urlbar.maxRichResults"; +const MAX_FORM_HISTORY_PREF = "browser.urlbar.maxHistoricalSearchSuggestions"; +const SEARCH_STRING = "hello"; +const MATCH_BUCKETS_VALUE = "general:5,suggestion:Infinity"; + +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 > 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(); +} + +async function cleanUpSuggestions() { + await cleanup(); + if (previousSuggestionsFn) { + suggestionsFn = previousSuggestionsFn; + previousSuggestionsFn = null; + } +} + +function makeExpectedFormHistoryResults(context, minCount = 0) { + let count = Math.max( + minCount, + Services.prefs.getIntPref(MAX_FORM_HISTORY_PREF, 0) + ); + let results = []; + for (let i = 0; i < count; i++) { + results.push( + makeFormHistoryResult(context, { + suggestion: `${SEARCH_STRING} world Form History ${i}`, + engineName: ENGINE_NAME, + }) + ); + } + return results; +} + +function makeExpectedRemoteSuggestionResults( + context, + { suggestionPrefix = SEARCH_STRING, query = undefined } = {} +) { + return [ + makeSearchResult(context, { + query, + engineName: ENGINE_NAME, + suggestion: suggestionPrefix + " foo", + }), + makeSearchResult(context, { + query, + engineName: ENGINE_NAME, + suggestion: suggestionPrefix + " bar", + }), + ]; +} + +function makeExpectedSuggestionResults( + context, + { suggestionPrefix = SEARCH_STRING, query = undefined } = {} +) { + return [ + ...makeExpectedFormHistoryResults(context), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix, + query, + }), + ]; +} + +add_task(async function setup() { + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + MATCH_BUCKETS_VALUE + ); + + let engine = await addTestSuggestionsEngine(searchStr => { + return suggestionsFn(searchStr); + }); + 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); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + }); + Services.search.setDefault(engine); + Services.prefs.setBoolPref(PRIVATE_SEARCH_PREF, false); + + // Add some form history. + let context = createContext(SEARCH_STRING, { isPrivate: false }); + let entries = makeExpectedFormHistoryResults(context, 2).map(r => ({ + value: r.payload.suggestion, + source: 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: 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: 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: 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: ENGINE_NAME, + heuristic: true, + }), + ...makeExpectedSuggestionResults(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: 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: ENGINE_NAME, + heuristic: true, + }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "baz " + SEARCH_STRING, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "quux " + SEARCH_STRING, + }), + ], + }); + + await cleanUpSuggestions(); +}); + +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: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "aaa", + }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + 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`, + }), + ...makeExpectedSuggestionResults(context), + ], + }); + + // 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: ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: SEARCH_STRING, + heuristic: true, + }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, + query: "", + heuristic: true, + }), + ...makeExpectedFormHistoryResults(context), + ], + }); + + // Also if followed by multiple spaces. + context = createContext(`${UrlbarTokenizer.RESTRICT.SEARCH} `, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "", + heuristic: true, + }), + ...makeExpectedFormHistoryResults(context), + ], + }); + + // 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: ENGINE_NAME, + query: "h", + heuristic: true, + }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, + alias: UrlbarTokenizer.RESTRICT.SEARCH, + query: "h", + heuristic: true, + }), + ...makeExpectedSuggestionResults(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: ENGINE_NAME, heuristic: true }), + ], + }); + + await cleanUpSuggestions(); +}); + +add_task(async function mixup_frecency() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + // At most, we should have 14 results in this subtest. We set this to 20 to + // make we're not cutting off any results and we are actually getting 12. + Services.prefs.setIntPref(MAX_RICH_RESULTS_PREF, 20); + + // 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: ENGINE_NAME, heuristic: true }), + 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`, + }), + ...makeExpectedSuggestionResults(context), + 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 "general" context mixup. + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + "suggestion:1,general:5,suggestion:1" + ); + + // 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(context).slice(0, 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`, + }), + ...makeExpectedSuggestionResults(context).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`, + }), + ], + }); + + // Change the "search" context mixup. + Services.prefs.setCharPref( + "browser.urlbar.matchBucketsSearch", + "suggestion:2,general:4" + ); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(context).slice(0, 2), + 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`, + }), + ...makeExpectedSuggestionResults(context).slice(2), + 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`, + }), + ], + }); + + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + MATCH_BUCKETS_VALUE + ); + Services.prefs.clearUserPref("browser.urlbar.matchBucketsSearch"); + 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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}/`, + title: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + heuristic: false, + }), + ...makeExpectedFormHistoryResults(context), + ], + }); + + // 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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}/`, + title: `http://${SEARCH_STRING}/`, + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + heuristic: false, + }), + ...makeExpectedFormHistoryResults(context), + ], + }); + + context = createContext("somethingelse", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeVisitResult(context, { + source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, + uri: "http://somethingelse/", + title: "http://somethingelse/", + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedSuggestionResults(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/", + title: "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/", + title: "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/", + title: "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", + title: "data:text/plain,Content", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("a", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: 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, + title: `http://${query}/`, + uri: `http://${query}/`, + iconUri: "", + heuristic: true, + }), + makeSearchResult(context, { query, engineName: 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: 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: query, + }), + ], + }); + } + + await cleanUpSuggestions(); +}); + +add_task(async function avoid_remote_url_suggestions_1() { + Services.prefs.setBoolPref(SUGGEST_PREF, true); + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 1); + + 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: ENGINE_NAME, heuristic: true }), + makeFormHistoryResult(context, { + engineName: ENGINE_NAME, + suggestion: `${query}.com`, + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: `${query}. com`, + }), + ], + }); + + await cleanUpSuggestions(); + await UrlbarTestUtils.formHistory.remove([`${query}.com`]); + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); +}); + +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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "htted", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "htteds", + }), + ], + }); + + context = createContext("ftp", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "ftped", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "ftpeds", + }), + ], + }); + + context = createContext("http", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httped", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httpeds", + }), + ], + }); + + context = createContext("http:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("https", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httpsed", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httpseds", + }), + ], + }); + + context = createContext("https:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("httpd", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httpded", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "httpdeds", + }), + ], + }); + + // Check FTP enabled + Services.prefs.setBoolPref("network.ftp.enabled", true); + registerCleanupFunction(() => + Services.prefs.clearUserPref("network.ftp.enabled") + ); + + context = createContext("ftp:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("ftp:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("ftp://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: 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/", + title: "ftp://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + // Check FTP disabled + Services.prefs.setBoolPref("network.ftp.enabled", false); + context = createContext("ftp:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("ftp:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("ftp://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: 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/", + title: "ftp://test/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("http:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("https:/", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("http://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("https://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: 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/", + title: "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/", + title: "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/", + title: "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/", + title: "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/", + title: "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/", + title: "http://www.test.com/", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("file", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "fileed", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "fileeds", + }), + ], + }); + + context = createContext("file:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: 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", + title: "file:///Users", + iconUri: "", + heuristic: true, + }), + ], + }); + + context = createContext("moz-test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("moz+test://", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + context = createContext("about", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "abouted", + }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: "abouteds", + }), + ], + }); + + context = createContext("about:", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ], + }); + + 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: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context), + ], + }); + + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context), + // 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: 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); + + // Setting maxHistoricalSearchSuggestions = 0 is special and indicates that + // the user has opted out of form history, so we should include form history + // neither before the expected remote results nor after, unlike the other + // checks below, where remaining form history is included after the expected + // remote results. + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 0); + let context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedRemoteSuggestionResults(context), + ], + }); + + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 1); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context, 2).slice(0, 1), + ...makeExpectedRemoteSuggestionResults(context), + ...makeExpectedFormHistoryResults(context, 2).slice(1), + ], + }); + + Services.prefs.setIntPref(MAX_FORM_HISTORY_PREF, 2); + context = createContext(SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedFormHistoryResults(context, 2), + ...makeExpectedRemoteSuggestionResults(context), + ], + }); + + // 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 second form + // history result should not be included since it doesn't match; and both + // remote suggestions should be included. + let firstSuggestion = makeExpectedFormHistoryResults(context)[0].payload + .suggestion; + context = createContext(firstSuggestion, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: firstSuggestion, + }), + ], + }); + + // Add these form history strings to use below. + let formHistoryStrings = ["foo", "foobar", "fooquux"]; + await UrlbarTestUtils.formHistory.add(formHistoryStrings); + + // Search for "foo". "foo" shouldn't be included since it dupes the + // heuristic. Both "foobar" and "fooquux" should be included even though the + // max form history count is only two and there are three matching form + // history results (including "foo"). + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: ENGINE_NAME, + }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + // Note that the second form history result appears after the remote + // suggestions. This isn't ideal because it should appear right after the + // first form history result, but it doesn't because the actual first form + // history result duped the heuristic, so the muxer discarded it. + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: ENGINE_NAME, + }), + ], + }); + + // 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. + 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: "foo.example.com", + heuristic: true, + }), + makeFormHistoryResult(context, { + suggestion: "foo", + engineName: ENGINE_NAME, + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: ENGINE_NAME, + }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: ENGINE_NAME, + }), + ], + }); + await PlacesUtils.history.clear(); + + // Add SERPs for "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" + // SERP depends on the match buckets, see below. + let engine = await Services.search.getDefault(); + let [serpURL1] = UrlbarUtils.getSearchQueryUrl(engine, "foobar"); + let [serpURL2] = UrlbarUtils.getSearchQueryUrl(engine, "food"); + await PlacesTestUtils.addVisits([serpURL1, serpURL2]); + + // First, use the MATCH_BUCKETS_VALUE that the test set above. General + // results appear before suggestions, which means that the muxer visits the + // "foobar" SERP before visiting the "foobar" form history, and so it doesn't + // see that the SERP dupes the form history. The "foobar" SERP is therefore + // included. + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + makeVisitResult(context, { + uri: "http://localhost:9000/search?terms=food", + title: "test visit for http://localhost:9000/search?terms=food", + }), + makeVisitResult(context, { + uri: "http://localhost:9000/search?terms=foobar", + title: "test visit for http://localhost:9000/search?terms=foobar", + }), + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: ENGINE_NAME, + }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: ENGINE_NAME, + }), + ], + }); + + // Now use Firefox's default match buckets, where suggestions appear before + // general results. Now the muxer will see that the "foobar" SERP dupes the + // "foobar" form history, so it will exclude the SERP. + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + "suggestion:4,general:Infinity" + ); + context = createContext("foo", { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { engineName: ENGINE_NAME, heuristic: true }), + // Note that the remote suggestions appear in between the two form history + // results. Ideally the form history would appear together before the + // remote suggestions, but they don't because the actual first form + // history result duped the heuristic, so the muxer discarded it. + makeFormHistoryResult(context, { + suggestion: "foobar", + engineName: ENGINE_NAME, + }), + ...makeExpectedRemoteSuggestionResults(context, { + suggestionPrefix: "foo", + }), + makeFormHistoryResult(context, { + suggestion: "fooquux", + engineName: ENGINE_NAME, + }), + makeVisitResult(context, { + uri: "http://localhost:9000/search?terms=food", + title: "test visit for http://localhost:9000/search?terms=food", + }), + ], + }); + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + MATCH_BUCKETS_VALUE + ); + + await PlacesUtils.history.clear(); + + await UrlbarTestUtils.formHistory.remove(formHistoryStrings); + + await cleanUpSuggestions(); + await PlacesUtils.history.clear(); + Services.prefs.clearUserPref(MAX_FORM_HISTORY_PREF); +}); 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..09a671b635 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_aliases.js @@ -0,0 +1,359 @@ +/* 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 SUGGESTIONS_ENGINE_NAME = "engine-suggestions.xml"; +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; + +add_task(async function setup() { + engine = await addTestSuggestionsEngine(); + + // 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. + Services.search.defaultEngine = await Services.search.addEngineWithDetails( + DEFAULT_ENGINE_NAME, + { template: "http://example.com/?s=%S" } + ); + + // 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:9000/search?terms=", + 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:9000/search?terms=", + 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:9000/search?terms=", + 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..6d690c1b99 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_search_suggestions_tail.js @@ -0,0 +1,358 @@ +/* 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 { FormHistory } = ChromeUtils.import( + "resource://gre/modules/FormHistory.jsm" +); + +const ENGINE_NAME = "engine-tail-suggestions.xml"; +const SUGGEST_PREF = "browser.urlbar.suggest.searches"; +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() { + Services.prefs.setCharPref( + "browser.urlbar.matchBuckets", + "general:5,suggestion:Infinity" + ); + + 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); + Services.prefs.clearUserPref(PRIVATE_SEARCH_PREF); + Services.prefs.clearUserPref(TAIL_SUGGESTIONS_PREF); + Services.prefs.clearUserPref(SUGGEST_ENABLED_PREF); + }); + Services.search.setDefault(engine); + 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); + + const query = "hello world"; + let context = createContext(query, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + engineName: "engine-suggestions.xml", + heuristic: true, + }), + makeSearchResult(context, { + engineName: "engine-suggestions.xml", + suggestion: query + " foo", + }), + makeSearchResult(context, { + engineName: "engine-suggestions.xml", + suggestion: query + " bar", + }), + ], + }); + + Services.search.setDefault(tailEngine); + 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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: query + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: 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", + }); + + // 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: 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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: ENGINE_NAME, + suggestion: tQuery + "oronto", + tail: "toronto", + }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + makeFormHistoryResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + makeSearchResult(context, { + engineName: 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: ENGINE_NAME, heuristic: true }), + ], + }); + + Services.prefs.setBoolPref(TAIL_SUGGESTIONS_PREF, oldPrefValue); + await cleanUpSuggestions(); +}); 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..22dc629a46 --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_tokenizer.js @@ -0,0 +1,450 @@ +/* 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..1d275fef3a --- /dev/null +++ b/browser/components/urlbar/tests/unit/test_trimming.js @@ -0,0 +1,222 @@ +/* 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/", + title: "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: "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/", + title: "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: "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/", + title: "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: "www.mozilla.org/test/", + heuristic: true, + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_ftp() { + info("Searching for untrimmed ftp:// entry"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("ftp://mozilla.org/test/"), + }); + let context = createContext("mo", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/", + completed: "ftp://mozilla.org/", + matches: [ + makeVisitResult(context, { + uri: "ftp://mozilla.org/", + title: "ftp://mozilla.org", + heuristic: true, + }), + makeVisitResult(context, { + uri: "ftp://mozilla.org/test/", + title: "test visit for ftp://mozilla.org/test/", + }), + ], + }); + await cleanupPlaces(); +}); + +add_task(async function test_untrimmed_ftp_path() { + info("Searching for untrimmed ftp:// entry with path"); + await PlacesTestUtils.addVisits({ + uri: Services.io.newURI("ftp://mozilla.org/test/"), + }); + let context = createContext("mozilla.org/t", { isPrivate: false }); + await check_results({ + context, + autofilled: "mozilla.org/test/", + completed: "ftp://mozilla.org/test/", + matches: [ + makeVisitResult(context, { + uri: "ftp://mozilla.org/test/", + title: "ftp://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.OTHER_LOCAL, + uri: "https://www.mozilla.org/%E5%95%8A-test", + title: "https://www.mozilla.org/啊-test", + iconUri: "page-icon:https://www.mozilla.org/", + heuristic: true, + }), + // UnifiedComplete escapes this character. + makeVisitResult(context, { + uri: "https://www.mozilla.org/%E5%95%8A-test", + title: "test visit for https://www.mozilla.org/%E5%95%8A-test", + }), + ], + }); + 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..555f8ac7ce --- /dev/null +++ b/browser/components/urlbar/tests/unit/xpcshell.ini @@ -0,0 +1,54 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +support-files = + data/engine-suggestions.xml + data/engine-tail-suggestions.xml + +[test_autofill_about_urls.js] +[test_autofill_bookmarked.js] +[test_autofill_functional.js] +[test_autofill_origins.js] +[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_casing.js] +[test_dedupe_prefix.js] +[test_dupe_urls.js] +[test_encoded_urls.js] +[test_heuristic_cancel.js] +[test_keywords.js] +skip-if = os == 'linux' # bug 1474616 +[test_muxer.js] +[test_providerHeuristicFallback.js] +[test_providerOmnibox.js] +[test_providerOpenTabs.js] +[test_providersManager.js] +[test_providersManager_filtering.js] +[test_providersManager_maxResults.js] +[test_providerTabToSearch.js] +[test_providerTabToSearch_partialHost.js] +[test_providerUnifiedComplete.js] +[test_providerUnifiedComplete_duplicate_entries.js] +[test_query_url.js] +[test_queryScorer.js] +[test_search_engine_host.js] +[test_search_suggestions.js] +[test_search_suggestions_aliases.js] +[test_search_suggestions_tail.js] +[test_tokenizer.js] +[test_trimming.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.jsm] +[test_UrlbarUtils_addToUrlbarHistory.js] +[test_UrlbarUtils_getShortcutOrURIAndPostData.js] +[test_UrlbarUtils_getTokenMatches.js] |