diff options
Diffstat (limited to 'browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs')
-rw-r--r-- | browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs | 1432 |
1 files changed, 1432 insertions, 0 deletions
diff --git a/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs new file mode 100644 index 0000000000..399fb7cc11 --- /dev/null +++ b/browser/components/urlbar/tests/UrlbarTestUtils.sys.mjs @@ -0,0 +1,1432 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { + UrlbarProvider, + UrlbarUtils, +} from "resource:///modules/UrlbarUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarController: "resource:///modules/UrlbarController.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm", + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", +}); + +export var UrlbarTestUtils = { + /** + * This maps the categories used by the FX_URLBAR_SELECTED_RESULT_METHOD and + * FX_SEARCHBAR_SELECTED_RESULT_METHOD histograms to their indexes in the + * `labels` array. This only needs to be used by tests that need to map from + * category names to indexes in histogram snapshots. Actual app code can use + * these category names directly when they add to a histogram. + */ + SELECTED_RESULT_METHODS: { + enter: 0, + enterSelection: 1, + click: 2, + arrowEnterSelection: 3, + tabEnterSelection: 4, + rightClickEnter: 5, + }, + + // Fallback to the console. + info: console.log, + + /** + * Running this init allows helpers to access test scope helpers, like Assert + * and SimpleTest. Note this initialization is not enforced, thus helpers + * should always check the properties set here and provide a fallback path. + * + * @param {object} scope The global scope where tests are being run. + */ + init(scope) { + if (!scope) { + throw new Error("Must initialize UrlbarTestUtils with a test scope"); + } + // If you add other properties to `this`, null them in uninit(). + this.Assert = scope.Assert; + this.info = scope.info; + this.registerCleanupFunction = scope.registerCleanupFunction; + + if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + this.initXPCShellDependencies(); + } else { + // xpcshell doesn't support EventUtils. + this.EventUtils = scope.EventUtils; + this.SimpleTest = scope.SimpleTest; + } + + this.registerCleanupFunction(() => { + this.Assert = null; + this.info = console.log; + this.registerCleanupFunction = null; + this.EventUtils = null; + this.SimpleTest = null; + }); + }, + + /** + * Waits to a search to be complete. + * + * @param {object} win The window containing the urlbar + * @returns {Promise} Resolved when done. + */ + async promiseSearchComplete(win) { + let waitForQuery = () => { + return this.promisePopupOpen(win, () => {}).then( + () => win.gURLBar.lastQueryContextPromise + ); + }; + let context = await waitForQuery(); + if (win.gURLBar.searchMode) { + // Search mode may start a second query. + context = await waitForQuery(); + } + return context; + }, + + /** + * Starts a search for a given string and waits for the search to be complete. + * + * @param {object} options The options object. + * @param {object} options.window The window containing the urlbar + * @param {string} options.value the search string + * @param {Function} options.waitForFocus The SimpleTest function + * @param {boolean} [options.fireInputEvent] whether an input event should be + * used when starting the query (simulates the user's typing, sets + * userTypedValued, triggers engagement event telemetry, etc.) + * @param {number} [options.selectionStart] The input's selectionStart + * @param {number} [options.selectionEnd] The input's selectionEnd + */ + async promiseAutocompleteResultPopup({ + window, + value, + waitForFocus, + fireInputEvent = true, + selectionStart = -1, + selectionEnd = -1, + } = {}) { + if (this.SimpleTest) { + await this.SimpleTest.promiseFocus(window); + } else { + await new Promise(resolve => waitForFocus(resolve, window)); + } + + const setup = () => { + window.gURLBar.inputField.focus(); + // Using the value setter in some cases may trim and fetch unexpected + // results, then pick an alternate path. + if ( + lazy.UrlbarPrefs.get("trimURLs") && + value != lazy.BrowserUIUtils.trimURL(value) + ) { + window.gURLBar.inputField.value = value; + fireInputEvent = true; + } else { + window.gURLBar.value = value; + } + if (selectionStart >= 0 && selectionEnd >= 0) { + window.gURLBar.selectionEnd = selectionEnd; + window.gURLBar.selectionStart = selectionStart; + } + + // An input event will start a new search, so be careful not to start a + // search if we fired an input event since that would start two searches. + if (fireInputEvent) { + // This is necessary to get the urlbar to set gBrowser.userTypedValue. + this.fireInputEvent(window); + } else { + window.gURLBar.setPageProxyState("invalid"); + window.gURLBar.startQuery(); + } + }; + setup(); + + // In Linux TV test, as there is case that the input field lost the focus + // until showing popup, timeout failure happens since the expected poup + // never be shown. To avoid this, if losing the focus, retry setup to open + // popup. + const blurListener = () => { + setup(); + }; + window.gURLBar.inputField.addEventListener("blur", blurListener, { + once: true, + }); + const result = await this.promiseSearchComplete(window); + window.gURLBar.inputField.removeEventListener("blur", blurListener); + return result; + }, + + /** + * Waits for a result to be added at a certain index. Since we implement lazy + * results replacement, even if we have a result at an index, it may be + * related to the previous query, this methods ensures the result is current. + * + * @param {object} win The window containing the urlbar + * @param {number} index The index to look for + * @returns {HtmlElement|XulElement} the result's element. + */ + async waitForAutocompleteResultAt(win, index) { + // TODO Bug 1530338: Quantum Bar doesn't yet implement lazy results replacement. + await this.promiseSearchComplete(win); + let container = this.getResultsContainer(win); + if (index >= container.children.length) { + throw new Error("Not enough results"); + } + return container.children[index]; + }, + + /** + * Returns the oneOffSearchButtons object for the urlbar. + * + * @param {object} win The window containing the urlbar + * @returns {object} The oneOffSearchButtons + */ + getOneOffSearchButtons(win) { + return win.gURLBar.view.oneOffSearchButtons; + }, + + /** + * Returns a specific button of a result. + * + * @param {object} win The window containing the urlbar + * @param {string} buttonName The name of the button, e.g. "menu", "0", etc. + * @param {number} resultIndex The index of the result + * @returns {HtmlElement} The button + */ + getButtonForResultIndex(win, buttonName, resultIndex) { + return this.getRowAt(win, resultIndex).querySelector( + `.urlbarView-button-${buttonName}` + ); + }, + + /** + * Show the result menu button regardless of the result being hovered or + + selected. + * + * @param {object} win The window containing the urlbar + */ + disableResultMenuAutohide(win) { + let container = this.getResultsContainer(win); + let attr = "disable-resultmenu-autohide"; + container.toggleAttribute(attr, true); + this.registerCleanupFunction?.(() => { + container.toggleAttribute(attr, false); + }); + }, + + /** + * Opens the result menu of a specific result. + * + * @param {object} win The window containing the urlbar + * @param {object} [options] The options object. + * @param {number} [options.resultIndex] The index of the result. Defaults + * to the current selected index. + * @param {boolean} [options.byMouse] Whether to open the menu by mouse or + * keyboard. + * @param {string} [options.activationKey] Key to activate the button with, + * defaults to KEY_Enter. + */ + async openResultMenu( + win, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + byMouse = false, + activationKey = "KEY_Enter", + } = {} + ) { + this.Assert?.ok(win.gURLBar.view.isOpen, "view should be open"); + let menuButton = this.getButtonForResultIndex(win, "menu", resultIndex); + this.Assert?.ok( + menuButton, + `found the menu button at result index ${resultIndex}` + ); + let promiseMenuOpen = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "popupshown" + ); + if (byMouse) { + this.info( + `synthesizing mousemove on row to make the menu button visible` + ); + await this.EventUtils.promiseElementReadyForUserInput( + menuButton.closest(".urlbarView-row"), + win, + this.info + ); + this.info(`got mousemove, now clicking the menu button`); + this.EventUtils.synthesizeMouseAtCenter(menuButton, {}, win); + this.info(`waiting for the menu popup to open via mouse`); + } else { + this.info(`selecting the result at index ${resultIndex}`); + while (win.gURLBar.view.selectedRowIndex != resultIndex) { + this.EventUtils.synthesizeKey("KEY_ArrowDown", {}, win); + } + if (this.getSelectedElement(win) != menuButton) { + this.EventUtils.synthesizeKey("KEY_Tab", {}, win); + } + this.Assert?.equal( + this.getSelectedElement(win), + menuButton, + `selected the menu button at result index ${resultIndex}` + ); + this.EventUtils.synthesizeKey(activationKey, {}, win); + this.info(`waiting for ${activationKey} to open the menu popup`); + } + await promiseMenuOpen; + this.Assert?.equal( + win.gURLBar.view.resultMenu.state, + "open", + "Checking popup state" + ); + }, + + /** + * Opens the result menu of a specific result and gets a menu item by either + * accesskey or command name. Either `accesskey` or `command` must be given. + * + * @param {object} options + * The options object. + * @param {object} options.window + * The window containing the urlbar. + * @param {string} options.accesskey + * The access key of the menu item to return. + * @param {string} options.command + * The command name of the menu item to return. + * @param {number} options.resultIndex + * The index of the result. Defaults to the current selected index. + * @param {boolean} options.openByMouse + * Whether to open the menu by mouse or keyboard. + * @param {Array} options.submenuSelectors + * If the command is in the top-level result menu, leave this as an empty + * array. If it's in a submenu, set this to an array where each element i is + * a selector that can be used to get the i'th menu item that opens a + * submenu. + */ + async openResultMenuAndGetItem({ + window, + accesskey, + command, + resultIndex = window.gURLBar.view.selectedRowIndex, + openByMouse = false, + submenuSelectors = [], + }) { + await this.openResultMenu(window, { resultIndex, byMouse: openByMouse }); + + // Open the sequence of submenus that contains the item. + for (let selector of submenuSelectors) { + let menuitem = window.gURLBar.view.resultMenu.querySelector(selector); + if (!menuitem) { + throw new Error("Submenu item not found for selector: " + selector); + } + + let promisePopup = lazy.BrowserTestUtils.waitForEvent( + window.gURLBar.view.resultMenu, + "popupshown" + ); + + if (AppConstants.platform == "macosx") { + // Synthesized clicks don't work in the native Mac menu. + this.info( + "Calling openMenu() on submenu item with selector: " + selector + ); + menuitem.openMenu(true); + } else { + this.info("Clicking submenu item with selector: " + selector); + this.EventUtils.synthesizeMouseAtCenter(menuitem, {}, window); + } + + this.info("Waiting for submenu popupshown event"); + await promisePopup; + this.info("Got the submenu popupshown event"); + } + + // Now get the item. + let menuitem; + if (accesskey) { + await lazy.BrowserTestUtils.waitForCondition(() => { + menuitem = window.gURLBar.view.resultMenu.querySelector( + `menuitem[accesskey=${accesskey}]` + ); + return menuitem; + }, "Waiting for strings to load"); + } else if (command) { + menuitem = window.gURLBar.view.resultMenu.querySelector( + `menuitem[data-command=${command}]` + ); + } else { + throw new Error("accesskey or command must be specified"); + } + + return menuitem; + }, + + /** + * Opens the result menu of a specific result and presses an access key to + * activate a menu item. + * + * @param {object} win The window containing the urlbar + * @param {string} accesskey The access key to press once the menu is open + * @param {object} [options] The options object. + * @param {number} [options.resultIndex] The index of the result. Defaults + * to the current selected index. + * @param {boolean} [options.openByMouse] Whether to open the menu by mouse + * or keyboard. + */ + async openResultMenuAndPressAccesskey( + win, + accesskey, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + openByMouse = false, + } = {} + ) { + let menuitem = await this.openResultMenuAndGetItem({ + accesskey, + resultIndex, + openByMouse, + window: win, + }); + if (!menuitem) { + throw new Error("Menu item not found for accesskey: " + accesskey); + } + + let promiseCommand = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "command" + ); + + if (AppConstants.platform == "macosx") { + // The native Mac menu doesn't support access keys. + this.info("calling doCommand() to activate menu item"); + menuitem.doCommand(); + win.gURLBar.view.resultMenu.hidePopup(true); + } else { + this.info(`pressing access key (${accesskey}) to activate menu item`); + this.EventUtils.synthesizeKey(accesskey, {}, win); + } + + this.info("waiting for command event"); + await promiseCommand; + this.info("got the command event"); + }, + + /** + * Opens the result menu of a specific result and clicks a menu item with a + * specified command name. + * + * @param {object} win + * The window containing the urlbar. + * @param {string|Array} commandOrArray + * If the command is in the top-level result menu, set this to the command + * name. If it's in a submenu, set this to an array where each element i is + * a selector that can be used to click the i'th menu item that opens a + * submenu, and the last element is the command name. + * @param {object} options + * The options object. + * @param {number} options.resultIndex + * The index of the result. Defaults to the current selected index. + * @param {boolean} options.openByMouse + * Whether to open the menu by mouse or keyboard. + */ + async openResultMenuAndClickItem( + win, + commandOrArray, + { + resultIndex = win.gURLBar.view.selectedRowIndex, + openByMouse = false, + } = {} + ) { + let submenuSelectors = Array.isArray(commandOrArray) + ? commandOrArray + : [commandOrArray]; + let command = submenuSelectors.pop(); + + let menuitem = await this.openResultMenuAndGetItem({ + resultIndex, + openByMouse, + command, + submenuSelectors, + window: win, + }); + if (!menuitem) { + throw new Error("Menu item not found for command: " + command); + } + + let promiseCommand = lazy.BrowserTestUtils.waitForEvent( + win.gURLBar.view.resultMenu, + "command" + ); + + if (AppConstants.platform == "macosx") { + // Synthesized clicks don't work in the native Mac menu. + this.info("calling doCommand() to activate menu item"); + menuitem.doCommand(); + win.gURLBar.view.resultMenu.hidePopup(true); + } else { + this.info("Clicking menu item with command: " + command); + this.EventUtils.synthesizeMouseAtCenter(menuitem, {}, win); + } + + this.info("Waiting for command event"); + await promiseCommand; + this.info("Got the command event"); + }, + + /** + * Returns true if the oneOffSearchButtons are visible. + * + * @param {object} win The window containing the urlbar + * @returns {boolean} True if the buttons are visible. + */ + getOneOffSearchButtonsVisible(win) { + let buttons = this.getOneOffSearchButtons(win); + return buttons.style.display != "none" && !buttons.container.hidden; + }, + + /** + * Gets an abstracted representation of the result at an index. + * + * @param {object} win The window containing the urlbar + * @param {number} index The index to look for + * @returns {object} An object with numerous properties describing the result. + */ + async getDetailsOfResultAt(win, index) { + let element = await this.waitForAutocompleteResultAt(win, index); + let details = {}; + let result = element.result; + details.result = result; + let { url, postData } = UrlbarUtils.getUrlFromResult(result); + details.url = url; + details.postData = postData; + details.type = result.type; + details.source = result.source; + details.heuristic = result.heuristic; + details.autofill = !!result.autofill; + details.image = + element.getElementsByClassName("urlbarView-favicon")[0]?.src; + details.title = result.title; + details.tags = "tags" in result.payload ? result.payload.tags : []; + details.isSponsored = result.payload.isSponsored; + let actions = element.getElementsByClassName("urlbarView-action"); + let urls = element.getElementsByClassName("urlbarView-url"); + let typeIcon = element.querySelector(".urlbarView-type-icon"); + await win.document.l10n.translateFragment(element); + details.displayed = { + title: element.getElementsByClassName("urlbarView-title")[0]?.textContent, + action: actions.length ? actions[0].textContent : null, + url: urls.length ? urls[0].textContent : null, + typeIcon: typeIcon + ? win.getComputedStyle(typeIcon)["background-image"] + : null, + }; + details.element = { + action: element.getElementsByClassName("urlbarView-action")[0], + row: element, + separator: element.getElementsByClassName( + "urlbarView-title-separator" + )[0], + title: element.getElementsByClassName("urlbarView-title")[0], + url: element.getElementsByClassName("urlbarView-url")[0], + }; + if (details.type == UrlbarUtils.RESULT_TYPE.SEARCH) { + details.searchParams = { + engine: result.payload.engine, + keyword: result.payload.keyword, + query: result.payload.query, + suggestion: result.payload.suggestion, + inPrivateWindow: result.payload.inPrivateWindow, + isPrivateEngine: result.payload.isPrivateEngine, + }; + } else if (details.type == UrlbarUtils.RESULT_TYPE.KEYWORD) { + details.keyword = result.payload.keyword; + } else if (details.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) { + details.dynamicType = result.payload.dynamicType; + } + return details; + }, + + /** + * Gets the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {HtmlElement|XulElement} The selected element. + */ + getSelectedElement(win) { + return win.gURLBar.view.selectedElement || null; + }, + + /** + * Gets the index of the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {number} The selected index. + */ + getSelectedElementIndex(win) { + return win.gURLBar.view.selectedElementIndex; + }, + + /** + * Gets the row at a specific index. + * + * @param {object} win The window containing the urlbar. + * @param {number} index The index to look for. + * @returns {HTMLElement|XulElement} The selected row. + */ + getRowAt(win, index) { + return this.getResultsContainer(win).children.item(index); + }, + + /** + * Gets the currently selected row. If the selected element is a descendant of + * a row, this will return the ancestor row. + * + * @param {object} win The window containing the urlbar. + * @returns {HTMLElement|XulElement} The selected row. + */ + getSelectedRow(win) { + return this.getRowAt(win, this.getSelectedRowIndex(win)); + }, + + /** + * Gets the index of the currently selected element. + * + * @param {object} win The window containing the urlbar. + * @returns {number} The selected row index. + */ + getSelectedRowIndex(win) { + return win.gURLBar.view.selectedRowIndex; + }, + + /** + * Selects the element at the index specified. + * + * @param {object} win The window containing the urlbar. + * @param {index} index The index to select. + */ + setSelectedRowIndex(win, index) { + win.gURLBar.view.selectedRowIndex = index; + }, + + getResultsContainer(win) { + return win.gURLBar.view.panel.querySelector(".urlbarView-results"); + }, + + /** + * Gets the number of results. + * You must wait for the query to be complete before using this. + * + * @param {object} win The window containing the urlbar + * @returns {number} the number of results. + */ + getResultCount(win) { + return this.getResultsContainer(win).children.length; + }, + + /** + * Ensures at least one search suggestion is present. + * + * @param {object} win The window containing the urlbar + * @returns {boolean} whether at least one search suggestion is present. + */ + promiseSuggestionsPresent(win) { + // TODO Bug 1530338: Quantum Bar doesn't yet implement lazy results replacement. When + // we do that, we'll have to be sure the suggestions we find are relevant + // for the current query. For now let's just wait for the search to be + // complete. + return this.promiseSearchComplete(win).then(context => { + // Look for search suggestions. + let firstSearchSuggestionIndex = context.results.findIndex( + r => r.type == UrlbarUtils.RESULT_TYPE.SEARCH && r.payload.suggestion + ); + if (firstSearchSuggestionIndex == -1) { + throw new Error("Cannot find a search suggestion"); + } + return firstSearchSuggestionIndex; + }); + }, + + /** + * Waits for the given number of connections to an http server. + * + * @param {object} httpserver an HTTP Server instance + * @param {number} count Number of connections to wait for + * @returns {Promise} resolved when all the expected connections were started. + */ + promiseSpeculativeConnections(httpserver, count) { + if (!httpserver) { + throw new Error("Must provide an http server"); + } + return lazy.BrowserTestUtils.waitForCondition( + () => httpserver.connectionNumber == count, + "Waiting for speculative connection setup" + ); + }, + + /** + * Waits for the popup to be shown. + * + * @param {object} win The window containing the urlbar + * @param {Function} openFn Function to be used to open the popup. + * @returns {Promise} resolved once the popup is closed + */ + async promisePopupOpen(win, openFn) { + if (!openFn) { + throw new Error("openFn should be supplied to promisePopupOpen"); + } + await openFn(); + if (win.gURLBar.view.isOpen) { + return; + } + this.info("Awaiting for the urlbar panel to open"); + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewOpen() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + this.info("Urlbar panel opened"); + }, + + /** + * Waits for the popup to be hidden. + * + * @param {object} win The window containing the urlbar + * @param {Function} [closeFn] Function to be used to close the popup, if not + * supplied it will default to a closing the popup directly. + * @returns {Promise} resolved once the popup is closed + */ + async promisePopupClose(win, closeFn = null) { + if (closeFn) { + await closeFn(); + } else { + win.gURLBar.view.close(); + } + if (!win.gURLBar.view.isOpen) { + return; + } + this.info("Awaiting for the urlbar panel to close"); + await new Promise(resolve => { + win.gURLBar.controller.addQueryListener({ + onViewClose() { + win.gURLBar.controller.removeQueryListener(this); + resolve(); + }, + }); + }); + this.info("Urlbar panel closed"); + }, + + /** + * Open the input field context menu and run a task on it. + * + * @param {nsIWindow} win the current window + * @param {Function} task a task function to run, gets the contextmenu popup + * as argument. + */ + async withContextMenu(win, task) { + let textBox = win.gURLBar.querySelector("moz-input-box"); + let cxmenu = textBox.menupopup; + let openPromise = lazy.BrowserTestUtils.waitForEvent(cxmenu, "popupshown"); + this.EventUtils.synthesizeMouseAtCenter( + win.gURLBar.inputField, + { + type: "contextmenu", + button: 2, + }, + win + ); + await openPromise; + // On Mac sometimes the menuitems are not ready. + await new Promise(win.requestAnimationFrame); + try { + await task(cxmenu); + } finally { + // Close the context menu if the task didn't pick anything. + if (cxmenu.state == "open" || cxmenu.state == "showing") { + let closePromise = lazy.BrowserTestUtils.waitForEvent( + cxmenu, + "popuphidden" + ); + cxmenu.hidePopup(); + await closePromise; + } + } + }, + + /** + * @param {object} win The browser window + * @returns {boolean} Whether the popup is open + */ + isPopupOpen(win) { + return win.gURLBar.view.isOpen; + }, + + /** + * Asserts that the input is in a given search mode, or no search mode. Can + * only be used if UrlbarTestUtils has been initialized with init(). + * + * @param {Window} window + * The browser window. + * @param {object} expectedSearchMode + * The expected search mode object. + */ + async assertSearchMode(window, expectedSearchMode) { + this.Assert.equal( + !!window.gURLBar.searchMode, + window.gURLBar.hasAttribute("searchmode"), + "Urlbar should never be in search mode without the corresponding attribute." + ); + + this.Assert.equal( + !!window.gURLBar.searchMode, + !!expectedSearchMode, + "gURLBar.searchMode should exist as expected" + ); + + if ( + window.gURLBar.searchMode?.source && + window.gURLBar.searchMode.source !== UrlbarUtils.RESULT_SOURCE.SEARCH + ) { + this.Assert.equal( + window.gURLBar.getAttribute("searchmodesource"), + UrlbarUtils.getResultSourceName(window.gURLBar.searchMode.source), + "gURLBar has proper searchmodesource attribute" + ); + } else { + this.Assert.ok( + !window.gURLBar.hasAttribute("searchmodesource"), + "gURLBar does not have searchmodesource attribute" + ); + } + + if (!expectedSearchMode) { + // Check the input's placeholder. + const prefName = + "browser.urlbar.placeholderName" + + (lazy.PrivateBrowsingUtils.isWindowPrivate(window) ? ".private" : ""); + let engineName = Services.prefs.getStringPref(prefName, ""); + this.Assert.deepEqual( + window.document.l10n.getAttributes(window.gURLBar.inputField), + engineName + ? { id: "urlbar-placeholder-with-name", args: { name: engineName } } + : { id: "urlbar-placeholder", args: null }, + "Expected placeholder l10n when search mode is inactive" + ); + return; + } + + // Default to full search mode for less verbose tests. + expectedSearchMode = { ...expectedSearchMode }; + if (!expectedSearchMode.hasOwnProperty("isPreview")) { + expectedSearchMode.isPreview = false; + } + + let isGeneralPurposeEngine = false; + if (expectedSearchMode.engineName) { + let engine = Services.search.getEngineByName( + expectedSearchMode.engineName + ); + isGeneralPurposeEngine = engine.isGeneralPurposeEngine; + expectedSearchMode.isGeneralPurposeEngine = isGeneralPurposeEngine; + } + + // expectedSearchMode may come from UrlbarUtils.LOCAL_SEARCH_MODES. The + // objects in that array include useful metadata like icon URIs and pref + // names that are not usually included in actual search mode objects. For + // convenience, ignore those properties if they aren't also present in the + // urlbar's actual search mode object. + let ignoreProperties = ["icon", "pref", "restrict", "telemetryLabel"]; + for (let prop of ignoreProperties) { + if (prop in expectedSearchMode && !(prop in window.gURLBar.searchMode)) { + this.info( + `Ignoring unimportant property '${prop}' in expected search mode` + ); + delete expectedSearchMode[prop]; + } + } + + this.Assert.deepEqual( + window.gURLBar.searchMode, + expectedSearchMode, + "Expected searchMode" + ); + + // Check the textContent and l10n attributes of the indicator and label. + let expectedTextContent = ""; + let expectedL10n = { id: null, args: null }; + if (expectedSearchMode.engineName) { + expectedTextContent = expectedSearchMode.engineName; + } else if (expectedSearchMode.source) { + let name = UrlbarUtils.getResultSourceName(expectedSearchMode.source); + this.Assert.ok(name, "Expected result source should have a name"); + expectedL10n = { id: `urlbar-search-mode-${name}`, args: null }; + } else { + this.Assert.ok(false, "Unexpected searchMode"); + } + + for (let element of [ + window.gURLBar._searchModeIndicatorTitle, + window.gURLBar._searchModeLabel, + ]) { + if (expectedTextContent) { + this.Assert.equal( + element.textContent, + expectedTextContent, + "Expected textContent" + ); + } + this.Assert.deepEqual( + window.document.l10n.getAttributes(element), + expectedL10n, + "Expected l10n" + ); + } + + // Check the input's placeholder. + let expectedPlaceholderL10n; + if (expectedSearchMode.engineName) { + expectedPlaceholderL10n = { + id: isGeneralPurposeEngine + ? "urlbar-placeholder-search-mode-web-2" + : "urlbar-placeholder-search-mode-other-engine", + args: { name: expectedSearchMode.engineName }, + }; + } else if (expectedSearchMode.source) { + let name = UrlbarUtils.getResultSourceName(expectedSearchMode.source); + expectedPlaceholderL10n = { + id: `urlbar-placeholder-search-mode-other-${name}`, + args: null, + }; + } + this.Assert.deepEqual( + window.document.l10n.getAttributes(window.gURLBar.inputField), + expectedPlaceholderL10n, + "Expected placeholder l10n when search mode is active" + ); + + // If this is an engine search mode, check that all results are either + // search results with the same engine or have the same host as the engine. + // Search mode preview can show other results since it is not supposed to + // start a query. + if ( + expectedSearchMode.engineName && + !expectedSearchMode.isPreview && + this.isPopupOpen(window) + ) { + let resultCount = this.getResultCount(window); + for (let i = 0; i < resultCount; i++) { + let result = await this.getDetailsOfResultAt(window, i); + if (result.source == UrlbarUtils.RESULT_SOURCE.SEARCH) { + this.Assert.equal( + expectedSearchMode.engineName, + result.searchParams.engine, + "Search mode result matches engine name." + ); + } else { + let engine = Services.search.getEngineByName( + expectedSearchMode.engineName + ); + let engineRootDomain = + lazy.UrlbarSearchUtils.getRootDomainFromEngine(engine); + let resultUrl = new URL(result.url); + this.Assert.ok( + resultUrl.hostname.includes(engineRootDomain), + "Search mode result matches engine host." + ); + } + } + } + }, + + /** + * Enters search mode by clicking a one-off. The view must already be open + * before you call this. Can only be used if UrlbarTestUtils has been + * initialized with init(). + * + * @param {object} window + * The window to operate on. + * @param {object} searchMode + * If given, the one-off matching this search mode will be clicked; it + * should be a full search mode object as described in + * UrlbarInput.setSearchMode. If not given, the first one-off is clicked. + */ + async enterSearchMode(window, searchMode = null) { + this.info(`Enter Search Mode ${JSON.stringify(searchMode)}`); + + // Ensure any pending query is complete. + await this.promiseSearchComplete(window); + + // Ensure the the one-offs are finished rebuilding and visible. + let oneOffs = this.getOneOffSearchButtons(window); + await lazy.TestUtils.waitForCondition( + () => !oneOffs._rebuilding, + "Waiting for one-offs to finish rebuilding" + ); + this.Assert.equal( + UrlbarTestUtils.getOneOffSearchButtonsVisible(window), + true, + "One-offs are visible" + ); + + let buttons = oneOffs.getSelectableButtons(true); + if (!searchMode) { + searchMode = { engineName: buttons[0].engine.name }; + let engine = Services.search.getEngineByName(searchMode.engineName); + if (engine.isGeneralPurposeEngine) { + searchMode.source = UrlbarUtils.RESULT_SOURCE.SEARCH; + } + } + + if (!searchMode.entry) { + searchMode.entry = "oneoff"; + } + + let oneOff = buttons.find(o => + searchMode.engineName + ? o.engine.name == searchMode.engineName + : o.source == searchMode.source + ); + this.Assert.ok(oneOff, "Found one-off button for search mode"); + this.EventUtils.synthesizeMouseAtCenter(oneOff, {}, window); + await this.promiseSearchComplete(window); + this.Assert.ok(this.isPopupOpen(window), "Urlbar view is still open."); + await this.assertSearchMode(window, searchMode); + }, + + /** + * Exits search mode. If neither `backspace` nor `clickClose` is given, we'll + * default to backspacing. Can only be used if UrlbarTestUtils has been + * initialized with init(). + * + * @param {object} window + * The window to operate on. + * @param {object} options + * Options object + * @param {boolean} options.backspace + * Exits search mode by backspacing at the beginning of the search string. + * @param {boolean} options.clickClose + * Exits search mode by clicking the close button on the search mode + * indicator. + * @param {boolean} [options.waitForSearch] + * Whether the test should wait for a search after exiting search mode. + * Defaults to true. + */ + async exitSearchMode( + window, + { backspace, clickClose, waitForSearch = true } = {} + ) { + let urlbar = window.gURLBar; + // If the Urlbar is not extended, ignore the clickClose parameter. The close + // button is not clickable in this state. This state might be encountered on + // Linux, where prefers-reduced-motion is enabled in automation. + if (!urlbar.hasAttribute("breakout-extend") && clickClose) { + if (waitForSearch) { + let searchPromise = UrlbarTestUtils.promiseSearchComplete(window); + urlbar.searchMode = null; + await searchPromise; + } else { + urlbar.searchMode = null; + } + return; + } + + if (!backspace && !clickClose) { + backspace = true; + } + + if (backspace) { + let urlbarValue = urlbar.value; + urlbar.selectionStart = urlbar.selectionEnd = 0; + if (waitForSearch) { + let searchPromise = this.promiseSearchComplete(window); + this.EventUtils.synthesizeKey("KEY_Backspace", {}, window); + await searchPromise; + } else { + this.EventUtils.synthesizeKey("KEY_Backspace", {}, window); + } + this.Assert.equal( + urlbar.value, + urlbarValue, + "Urlbar value hasn't changed." + ); + this.assertSearchMode(window, null); + } else if (clickClose) { + // We need to hover the indicator to make the close button clickable in the + // test. + let indicator = urlbar.querySelector("#urlbar-search-mode-indicator"); + this.EventUtils.synthesizeMouseAtCenter( + indicator, + { type: "mouseover" }, + window + ); + let closeButton = urlbar.querySelector( + "#urlbar-search-mode-indicator-close" + ); + if (waitForSearch) { + let searchPromise = this.promiseSearchComplete(window); + this.EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + await searchPromise; + } else { + this.EventUtils.synthesizeMouseAtCenter(closeButton, {}, window); + } + await this.assertSearchMode(window, null); + } + }, + + /** + * Returns the userContextId (container id) for the last search. + * + * @param {object} win The browser window + * @returns {Promise<number>} + * resolved when fetching is complete. Its value is a userContextId + */ + async promiseUserContextId(win) { + const defaultId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID; + let context = await win.gURLBar.lastQueryContextPromise; + return context.userContextId || defaultId; + }, + + /** + * Dispatches an input event to the input field. + * + * @param {object} win The browser window + */ + fireInputEvent(win) { + // Set event.data to the last character in the input, for a couple of + // reasons: It simulates the user typing, and it's necessary for autofill. + let event = new InputEvent("input", { + data: win.gURLBar.value[win.gURLBar.value.length - 1] || null, + }); + win.gURLBar.inputField.dispatchEvent(event); + }, + + /** + * Returns a new mock controller. This is useful for xpcshell tests. + * + * @param {object} options Additional options to pass to the UrlbarController + * constructor. + * @returns {UrlbarController} A new controller. + */ + newMockController(options = {}) { + return new lazy.UrlbarController( + Object.assign( + { + input: { + isPrivate: false, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }, + options + ) + ); + }, + + /** + * Initializes some external components used by the urlbar. This is necessary + * in xpcshell tests but not in browser tests. + */ + async initXPCShellDependencies() { + // The FormHistoryStartup component must be initialized since urlbar uses + // form history. + Cc["@mozilla.org/satchel/form-history-startup;1"] + .getService(Ci.nsIObserver) + .observe(null, "profile-after-change", null); + + // This is necessary because UrlbarMuxerUnifiedComplete.sort calls + // Services.search.parseSubmissionURL, so we need engines. + try { + await lazy.AddonTestUtils.promiseStartupManager(); + } catch (error) { + if (!error.message.includes("already started")) { + throw error; + } + } + }, + + /** + * Enrolls in a mock Nimbus feature. + * + * If you call UrlbarPrefs.updateFirefoxSuggestScenario() from an xpcshell + * test, you must call this first to intialize the Nimbus urlbar feature. + * + * @param {object} value + * Define any desired Nimbus variables in this object. + * @param {string} [feature] + * The feature to init. + * @param {string} [enrollmentType] + * The enrollment type, either "rollout" (default) or "config". + * @returns {Function} + * A cleanup function that will unenroll the feature, returns a promise. + */ + async initNimbusFeature( + value = {}, + feature = "urlbar", + enrollmentType = "rollout" + ) { + this.info("initNimbusFeature awaiting ExperimentManager.onStartup"); + await lazy.ExperimentManager.onStartup(); + + this.info("initNimbusFeature awaiting ExperimentAPI.ready"); + await lazy.ExperimentAPI.ready(); + + let method = + enrollmentType == "rollout" + ? "enrollWithRollout" + : "enrollWithFeatureConfig"; + this.info(`initNimbusFeature awaiting ExperimentFakes.${method}`); + let doCleanup = await lazy.ExperimentFakes[method]({ + featureId: lazy.NimbusFeatures[feature].featureId, + value: { enabled: true, ...value }, + }); + + this.info("initNimbusFeature done"); + + this.registerCleanupFunction?.(async () => { + // If `doCleanup()` has already been called (i.e., by the caller), it will + // throw an error here. + try { + await doCleanup(); + } catch (error) {} + }); + + return doCleanup; + }, + + /** + * Simulate that user clicks URLBar and inputs text into it. + * + * @param {object} win + * The browser window containing target gURLBar. + * @param {string} text + * The text to be input. + */ + async inputIntoURLBar(win, text) { + this.EventUtils.synthesizeMouseAtCenter(win.gURLBar.inputField, {}, win); + await lazy.BrowserTestUtils.waitForCondition( + () => win.document.activeElement === win.gURLBar.inputField + ); + this.EventUtils.sendString(text, win); + }, +}; + +UrlbarTestUtils.formHistory = { + /** + * Adds values to the urlbar's form history. + * + * @param {Array} values + * The form history entries to remove. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + add(values = [], window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.add(fieldname, values); + }, + + /** + * Removes values from the urlbar's form history. If you want to remove all + * history, use clearFormHistory. + * + * @param {Array} values + * The form history entries to remove. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + remove(values = [], window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.remove(fieldname, values); + }, + + /** + * Removes all values from the urlbar's form history. If you want to remove + * individual values, use removeFormHistory. + * + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} resolved once the operation is complete. + */ + clear(window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.clear(fieldname); + }, + + /** + * Searches the urlbar's form history. + * + * @param {object} criteria + * Criteria to narrow the search. See FormHistory.search. + * @param {object} window + * The window containing the urlbar. + * @returns {Promise} + * A promise resolved with an array of found form history entries. + */ + search(criteria = {}, window = lazy.BrowserWindowTracker.getTopWindow()) { + let fieldname = this.getFormHistoryName(window); + return lazy.FormHistoryTestUtils.search(fieldname, criteria); + }, + + /** + * Returns a promise that's resolved on the next form history change. + * + * @param {string} change + * Null to listen for any change, or one of: add, remove, update + * @returns {Promise} + * Resolved on the next specified form history change. + */ + promiseChanged(change = null) { + return lazy.TestUtils.topicObserved( + "satchel-storage-changed", + (subject, data) => !change || data == "formhistory-" + change + ); + }, + + /** + * Returns the form history name for the urlbar in a window. + * + * @param {object} window + * The window. + * @returns {string} + * The form history name of the urlbar in the window. + */ + getFormHistoryName(window = lazy.BrowserWindowTracker.getTopWindow()) { + return window ? window.gURLBar.formHistoryName : "searchbar-history"; + }, +}; + +/** + * A test provider. If you need a test provider whose behavior is different + * from this, then consider modifying the implementation below if you think the + * new behavior would be useful for other tests. Otherwise, you can create a + * new TestProvider instance and then override its methods. + */ +class TestProvider extends UrlbarProvider { + /** + * Constructor. + * + * @param {object} options + * Constructor options + * @param {Array} options.results + * An array of UrlbarResult objects that will be the provider's results. + * @param {string} [options.name] + * The provider's name. Provider names should be unique. + * @param {UrlbarUtils.PROVIDER_TYPE} [options.type] + * The provider's type. + * @param {number} [options.priority] + * The provider's priority. Built-in providers have a priority of zero. + * @param {number} [options.addTimeout] + * If non-zero, each result will be added on this timeout. If zero, all + * results will be added immediately and synchronously. + * @param {Function} [options.onCancel] + * If given, a function that will be called when the provider's cancelQuery + * method is called. + * @param {Function} [options.onSelection] + * If given, a function that will be called when + * {@link UrlbarView.#selectElement} method is called. + * @param {Function} [options.onEngagement] + * If given, a function that will be called when engagement. + */ + constructor({ + results, + name = "TestProvider" + Services.uuid.generateUUID(), + type = UrlbarUtils.PROVIDER_TYPE.PROFILE, + priority = 0, + addTimeout = 0, + onCancel = null, + onSelection = null, + onEngagement = null, + } = {}) { + super(); + this._results = results; + this._name = name; + this._type = type; + this._priority = priority; + this._addTimeout = addTimeout; + this._onCancel = onCancel; + this._onSelection = onSelection; + this._onEngagement = onEngagement; + } + get name() { + return this._name; + } + get type() { + return this._type; + } + getPriority(context) { + return this._priority; + } + isActive(context) { + return true; + } + async startQuery(context, addCallback) { + for (let result of this._results) { + if (!this._addTimeout) { + addCallback(this, result); + } else { + await new Promise(resolve => { + lazy.setTimeout(() => { + addCallback(this, result); + resolve(); + }, this._addTimeout); + }); + } + } + } + cancelQuery(context) { + if (this._onCancel) { + this._onCancel(); + } + } + + onSelection(result, element) { + if (this._onSelection) { + this._onSelection(result, element); + } + } + + onEngagement(isPrivate, state, queryContext, details) { + if (this._onEngagement) { + this._onEngagement(isPrivate, state, queryContext, details); + } + } +} + +UrlbarTestUtils.TestProvider = TestProvider; |