/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ /* eslint no-unused-vars: [2, {"vars": "local"}] */ "use strict"; /* globals Task, openToolboxForTab, gBrowser */ // shared-head.js handles imports, constants, and utility functions // Load the shared-head file first. Services.scriptloader.loadSubScript( "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", this ); // Import helpers for the new debugger Services.scriptloader.loadSubScript( "chrome://mochitests/content/browser/devtools/client/debugger/test/mochitest/shared-head.js", this ); Services.scriptloader.loadSubScript( "chrome://mochitests/content/browser/devtools/client/webconsole/test/browser/shared-head.js", this ); var { BrowserConsoleManager, } = require("resource://devtools/client/webconsole/browser-console-manager.js"); var WCUL10n = require("resource://devtools/client/webconsole/utils/l10n.js"); const DOCS_GA_PARAMS = `?${new URLSearchParams({ utm_source: "mozilla", utm_medium: "firefox-console-errors", utm_campaign: "default", })}`; const GA_PARAMS = `?${new URLSearchParams({ utm_source: "mozilla", utm_medium: "devtools-webconsole", utm_campaign: "default", })}`; const wcActions = require("resource://devtools/client/webconsole/actions/index.js"); registerCleanupFunction(async function () { // Reset all cookies, tests loading sjs_slow-response-test-server.sjs will // set a foo cookie which might have side effects on other tests. Services.cookies.removeAll(); Services.prefs.clearUserPref("devtools.webconsole.ui.filterbar"); // Reset all filter prefs between tests. First flushPrefEnv in case one of the // filter prefs has been pushed for the test await SpecialPowers.flushPrefEnv(); Services.prefs.getChildList("devtools.webconsole.filter").forEach(pref => { Services.prefs.clearUserPref(pref); }); }); /** * Add a new tab and open the toolbox in it, and select the webconsole. * * @param string url * The URL for the tab to be opened. * @param Boolean clearJstermHistory * true (default) if the jsterm history should be cleared. * @param String hostId (optional) * The type of toolbox host to be used. * @return Promise * Resolves when the tab has been added, loaded and the toolbox has been opened. * Resolves to the hud. */ async function openNewTabAndConsole(url, clearJstermHistory = true, hostId) { const toolbox = await openNewTabAndToolbox(url, "webconsole", hostId); const hud = toolbox.getCurrentPanel().hud; if (clearJstermHistory) { // Clearing history that might have been set in previous tests. await hud.ui.wrapper.dispatchClearHistory(); } return hud; } /** * Add a new tab with iframes, open the toolbox in it, and select the webconsole. * * @param string url * The URL for the tab to be opened. * @param Arra iframes * An array of URLs that will be added to the top document. * @return Promise * Resolves when the tab has been added, loaded, iframes loaded, and the toolbox * has been opened. Resolves to the hud. */ async function openNewTabWithIframesAndConsole(tabUrl, iframes) { // We need to add the tab and the iframes before opening the console in case we want // to handle remote frames (we don't support creating frames target when the toolbox // is already open). await addTab(tabUrl); await ContentTask.spawn( gBrowser.selectedBrowser, iframes, async function (urls) { const iframesLoadPromises = urls.map((url, i) => { const iframe = content.document.createElement("iframe"); iframe.classList.add(`iframe-${i + 1}`); const onLoadIframe = new Promise(resolve => { iframe.addEventListener("load", resolve, { once: true }); }); content.document.body.append(iframe); iframe.src = url; return onLoadIframe; }); await Promise.all(iframesLoadPromises); } ); return openConsole(); } /** * Open a new window with a tab,open the toolbox, and select the webconsole. * * @param string url * The URL for the tab to be opened. * @return Promise<{win, hud, tab}> * Resolves when the tab has been added, loaded and the toolbox has been opened. * Resolves to the toolbox. */ async function openNewWindowAndConsole(url) { const win = await BrowserTestUtils.openNewBrowserWindow(); const tab = await addTab(url, { window: win }); win.gBrowser.selectedTab = tab; const hud = await openConsole(tab); return { win, hud, tab }; } /** * Subscribe to the store and log out stringinfied versions of messages. * This is a helper function for debugging, to make is easier to see what * happened during the test in the log. * * @param object hud */ function logAllStoreChanges(hud) { const store = hud.ui.wrapper.getStore(); // Adding logging each time the store is modified in order to check // the store state in case of failure. store.subscribe(() => { const messages = [ ...store.getState().messages.mutableMessagesById.values(), ]; const debugMessages = messages.map( ({ id, type, parameters, messageText }) => { return { id, type, parameters, messageText }; } ); info( "messages : " + JSON.stringify(debugMessages, function (key, value) { if (value && value.getGrip) { return value.getGrip(); } return value; }) ); }); } /** * Wait for messages with given message type in the web console output, * resolving once they are received. * * @param object options * - hud: the webconsole * - messages: Array[Object]. An array of messages to match. * Current supported options: * - text: {String} Partial text match in .message-body * - typeSelector: {String} A part of selector for the message, to * specify the message type. * @return promise * A promise that is resolved to an array of the message nodes */ function waitForMessagesByType({ hud, messages }) { return new Promise(resolve => { const matchedMessages = []; hud.ui.on("new-messages", function messagesReceived(newMessages) { for (const message of messages) { if (message.matched) { continue; } const typeSelector = message.typeSelector; if (!typeSelector) { throw new Error("typeSelector property is required"); } if (!typeSelector.startsWith(".")) { throw new Error( "typeSelector property start with a dot e.g. `.result`" ); } const selector = ".message" + typeSelector; for (const newMessage of newMessages) { const messageBody = newMessage.node.querySelector(`.message-body`); if ( messageBody && newMessage.node.matches(selector) && messageBody.textContent.includes(message.text) ) { matchedMessages.push(newMessage); message.matched = true; const messagesLeft = messages.length - matchedMessages.length; info( `Matched a message with text: "${message.text}", ` + (messagesLeft > 0 ? `still waiting for ${messagesLeft} messages.` : `all messages received.`) ); break; } } if (matchedMessages.length === messages.length) { hud.ui.off("new-messages", messagesReceived); resolve(matchedMessages); return; } } }); }); } /** * Wait for a message with the provided text and showing the provided repeat count. * * @param {Object} hud : the webconsole * @param {String} text : text included in .message-body * @param {String} typeSelector : A part of selector for the message, to * specify the message type. * @param {Number} repeat : expected repeat count in .message-repeats */ function waitForRepeatedMessageByType(hud, text, typeSelector, repeat) { return waitFor(() => { // Wait for a message matching the provided text. const node = findMessageByType(hud, text, typeSelector); if (!node) { return false; } // Check if there is a repeat node with the expected count. const repeatNode = node.querySelector(".message-repeats"); if (repeatNode && parseInt(repeatNode.textContent, 10) === repeat) { return node; } return false; }); } /** * Wait for a single message with given message type in the web console output, * resolving with the first message that matches the query once it is received. * * @param {Object} hud : the webconsole * @param {String} text : text included in .message-body * @param {String} typeSelector : A part of selector for the message, to * specify the message type. * @return promise * A promise that is resolved to the message node */ async function waitForMessageByType(hud, text, typeSelector) { const messages = await waitForMessagesByType({ hud, messages: [{ text, typeSelector }], }); return messages[0]; } /** * Execute an input expression. * * @param {Object} hud : The webconsole. * @param {String} input : The input expression to execute. */ function execute(hud, input) { return hud.ui.wrapper.dispatchEvaluateExpression(input); } /** * Execute an input expression and wait for a message with the expected text * with given message type to be displayed in the output. * * @param {Object} hud : The webconsole. * @param {String} input : The input expression to execute. * @param {String} matchingText : A string that should match the message body content. * @param {String} typeSelector : A part of selector for the message, to * specify the message type. */ function executeAndWaitForMessageByType( hud, input, matchingText, typeSelector ) { const onMessage = waitForMessageByType(hud, matchingText, typeSelector); execute(hud, input); return onMessage; } /** * Type-specific wrappers for executeAndWaitForMessageByType * * @param {Object} hud : The webconsole. * @param {String} input : The input expression to execute. * @param {String} matchingText : A string that should match the message body * content. */ function executeAndWaitForResultMessage(hud, input, matchingText) { return executeAndWaitForMessageByType(hud, input, matchingText, ".result"); } function executeAndWaitForErrorMessage(hud, input, matchingText) { return executeAndWaitForMessageByType(hud, input, matchingText, ".error"); } /** * Set the input value, simulates the right keyboard event to evaluate it, * depending on if the console is in editor mode or not, and wait for a message * with the expected text with given message type to be displayed in the output. * * @param {Object} hud : The webconsole. * @param {String} input : The input expression to execute. * @param {String} matchingText : A string that should match the message body * content. * @param {String} typeSelector : A part of selector for the message, to * specify the message type. */ function keyboardExecuteAndWaitForMessageByType( hud, input, matchingText, typeSelector ) { hud.jsterm.focus(); setInputValue(hud, input); const onMessage = waitForMessageByType(hud, matchingText, typeSelector); if (isEditorModeEnabled(hud)) { EventUtils.synthesizeKey("KEY_Enter", { [Services.appinfo.OS === "Darwin" ? "metaKey" : "ctrlKey"]: true, }); } else { EventUtils.synthesizeKey("VK_RETURN"); } return onMessage; } /** * Type-specific wrappers for keyboardExecuteAndWaitForMessageByType * * @param {Object} hud : The webconsole. * @param {String} input : The input expression to execute. * @param {String} matchingText : A string that should match the message body * content. */ function keyboardExecuteAndWaitForResultMessage(hud, input, matchingText) { return keyboardExecuteAndWaitForMessageByType( hud, input, matchingText, ".result" ); } /** * Wait for a message to be logged and ensure it is logged only once. * * @param object hud * The web console. * @param string text * A substring that can be found in the message. * @param string typeSelector * A part of selector for the message, to specify the message type. * @return {Node} the node corresponding the found message */ async function checkUniqueMessageExists(hud, msg, typeSelector) { info(`Checking "${msg}" was logged`); let messages; try { messages = await waitFor(async () => { const msgs = await findMessagesVirtualizedByType({ hud, text: msg, typeSelector, }); return msgs.length ? msgs : null; }); } catch (e) { ok(false, `Message "${msg}" wasn't logged\n`); return null; } is(messages.length, 1, `"${msg}" was logged once`); const [messageEl] = messages; const repeatNode = messageEl.querySelector(".message-repeats"); is(repeatNode, null, `"${msg}" wasn't repeated`); return messageEl; } /** * Simulate a context menu event on the provided element, and wait for the console context * menu to open. Returns a promise that resolves the menu popup element. * * @param object hud * The web console. * @param element element * The dom element on which the context menu event should be synthesized. * @return promise */ async function openContextMenu(hud, element) { const onConsoleMenuOpened = hud.ui.wrapper.once("menu-open"); synthesizeContextMenuEvent(element); await onConsoleMenuOpened; return _getContextMenu(hud); } /** * Hide the webconsole context menu popup. Returns a promise that will resolve when the * context menu popup is hidden or immediately if the popup can't be found. * * @param object hud * The web console. * @return promise */ function hideContextMenu(hud) { const popup = _getContextMenu(hud); if (!popup || popup.state == "hidden") { return Promise.resolve(); } const onPopupHidden = once(popup, "popuphidden"); popup.hidePopup(); return onPopupHidden; } function _getContextMenu(hud) { const toolbox = hud.toolbox; const doc = toolbox ? toolbox.topWindow.document : hud.chromeWindow.document; return doc.getElementById("webconsole-menu"); } /** * Toggle Enable network monitoring setting * * @param object hud * The web console. * @param boolean shouldBeSwitchedOn * The expected state the setting should be in after the toggle. */ async function toggleNetworkMonitoringConsoleSetting(hud, shouldBeSwitchedOn) { const selector = ".webconsole-console-settings-menu-item-enableNetworkMonitoring"; const settingChanged = waitFor(() => { const el = getConsoleSettingElement(hud, selector); return shouldBeSwitchedOn ? el.getAttribute("aria-checked") === "true" : el.getAttribute("aria-checked") !== "true"; }); await toggleConsoleSetting(hud, selector); await settingChanged; } async function toggleConsoleSetting(hud, selector) { const toolbox = hud.toolbox; const doc = toolbox ? toolbox.doc : hud.chromeWindow.document; const menuItem = doc.querySelector(selector); menuItem.click(); } function getConsoleSettingElement(hud, selector) { const toolbox = hud.toolbox; const doc = toolbox ? toolbox.doc : hud.chromeWindow.document; return doc.querySelector(selector); } function checkConsoleSettingState(hud, selector, enabled) { const el = getConsoleSettingElement(hud, selector); const checked = el.getAttribute("aria-checked") === "true"; if (enabled) { ok(checked, "setting is enabled"); } else { ok(!checked, "setting is disabled"); } } /** * Returns a promise that resolves when the node passed as an argument mutate * according to the passed configuration. * * @param {Node} node - The node to observe mutations on. * @param {Object} observeConfig - A configuration object for MutationObserver.observe. * @returns {Promise} */ function waitForNodeMutation(node, observeConfig = {}) { return new Promise(resolve => { const observer = new MutationObserver(mutations => { resolve(mutations); observer.disconnect(); }); observer.observe(node, observeConfig); }); } /** * Search for a given message. When found, simulate a click on the * message's location, checking to make sure that the debugger opens * the corresponding URL. If the message was generated by a logpoint, * check if the corresponding logpoint editing panel is opened. * * @param {Object} hud * The webconsole * @param {Object} options * - text: {String} The text to search for. This should be contained in * the message. The searching is done with * @see findMessageByType. * - typeSelector: {string} A part of selector for the message, to * specify the message type. * - expectUrl: {boolean} Whether the URL in the opened source should * match the link, or whether it is expected to * be null. * - expectLine: {boolean} It indicates if there is the need to check * the line. * - expectColumn: {boolean} It indicates if there is the need to check * the column. * - logPointExpr: {String} The logpoint expression */ async function testOpenInDebugger( hud, { text, typeSelector, expectUrl = true, expectLine = true, expectColumn = true, logPointExpr = undefined, } ) { info(`Finding message for open-in-debugger test; text is "${text}"`); const messageNode = await waitFor(() => findMessageByType(hud, text, typeSelector) ); const locationNode = messageNode.querySelector(".message-location"); ok(locationNode, "The message does have a location link"); await checkClickOnNode( hud, hud.toolbox, locationNode, expectUrl, expectLine, expectColumn, logPointExpr ); } /** * Helper function for testOpenInDebugger. */ async function checkClickOnNode( hud, toolbox, frameLinkNode, expectUrl, expectLine, expectColumn, logPointExpr ) { info("checking click on node location"); // If the debugger hasn't fully loaded yet and breakpoints are still being // added when we click on the logpoint link, the logpoint panel might not // render. Work around this for now, see bug 1592854. await waitForTime(1000); const onSourceInDebuggerOpened = once(hud, "source-in-debugger-opened"); EventUtils.sendMouseEvent( { type: "click" }, frameLinkNode.querySelector(".frame-link-filename") ); await onSourceInDebuggerOpened; const dbg = toolbox.getPanel("jsdebugger"); // Wait for the source to finish loading, if it is pending. await waitFor( () => !!dbg._selectors.getSelectedSource(dbg._getState()) && !!dbg._selectors.getSelectedLocation(dbg._getState()) ); if (expectUrl) { const url = frameLinkNode.getAttribute("data-url"); ok(url, `source url found ("${url}")`); is( dbg._selectors.getSelectedSource(dbg._getState()).url, url, "expected source url" ); } if (expectLine) { const line = frameLinkNode.getAttribute("data-line"); ok(line, `source line found ("${line}")`); is( parseInt(dbg._selectors.getSelectedLocation(dbg._getState()).line, 10), parseInt(line, 10), "expected source line" ); } if (expectColumn) { const column = frameLinkNode.getAttribute("data-column"); ok(column, `source column found ("${column}")`); is( parseInt(dbg._selectors.getSelectedLocation(dbg._getState()).column, 10), parseInt(column, 10), "expected source column" ); } if (logPointExpr !== undefined && logPointExpr !== "") { const inputEl = dbg.panelWin.document.activeElement; is( inputEl.tagName, "TEXTAREA", "The textarea of logpoint panel is focused" ); const inputValue = inputEl.parentElement.parentElement.innerText.trim(); is( inputValue, logPointExpr, "The input in the open logpoint panel matches the logpoint expression" ); } } /** * Returns true if the give node is currently focused. */ function hasFocus(node) { return ( node.ownerDocument.activeElement == node && node.ownerDocument.hasFocus() ); } /** * Get the value of the console input . * * @param {WebConsole} hud: The webconsole * @returns {String}: The value of the console input. */ function getInputValue(hud) { return hud.jsterm._getValue(); } /** * Set the value of the console input . * * @param {WebConsole} hud: The webconsole * @param {String} value : The value to set the console input to. */ function setInputValue(hud, value) { const onValueSet = hud.jsterm.once("set-input-value"); hud.jsterm._setValue(value); return onValueSet; } /** * Set the value of the console input and its caret position, and wait for the * autocompletion to be updated. * * @param {WebConsole} hud: The webconsole * @param {String} value : The value to set the jsterm to. * @param {Integer} caretPosition : The index where to place the cursor. A negative * number will place the caret at (value.length - offset) position. * Default to value.length (caret set at the end). * @returns {Promise} resolves when the jsterm is completed. */ async function setInputValueForAutocompletion( hud, value, caretPosition = value.length ) { const { jsterm } = hud; const initialPromises = []; if (jsterm.autocompletePopup.isOpen) { initialPromises.push(jsterm.autocompletePopup.once("popup-closed")); } setInputValue(hud, ""); await Promise.all(initialPromises); // Wait for next tick. Tooltip tests sometimes fail to successively hide and // show tooltips on Win32 debug. await waitForTick(); jsterm.focus(); const updated = jsterm.once("autocomplete-updated"); EventUtils.sendString(value, hud.iframeWindow); await updated; // Wait for next tick. Tooltip tests sometimes fail to successively hide and // show tooltips on Win32 debug. await waitForTick(); if (caretPosition < 0) { caretPosition = value.length + caretPosition; } if (Number.isInteger(caretPosition)) { jsterm.editor.setCursor(jsterm.editor.getPosition(caretPosition)); } } /** * Set the value of the console input and wait for the confirm dialog to be displayed. * * @param {Toolbox} toolbox * @param {WebConsole} hud * @param {String} value : The value to set the jsterm to. * Default to value.length (caret set at the end). * @returns {Promise} resolves with dialog element when it is opened. */ async function setInputValueForGetterConfirmDialog(toolbox, hud, value) { await setInputValueForAutocompletion(hud, value); await waitFor(() => isConfirmDialogOpened(toolbox)); ok(true, "The confirm dialog is displayed"); return getConfirmDialog(toolbox); } /** * Checks if the console input has the expected completion value. * * @param {WebConsole} hud * @param {String} expectedValue * @param {String} assertionInfo: Description of the assertion passed to `is`. */ function checkInputCompletionValue(hud, expectedValue, assertionInfo) { const completionValue = getInputCompletionValue(hud); if (completionValue === null) { ok(false, "Couldn't retrieve the completion value"); } info(`Expects "${expectedValue}", is "${completionValue}"`); is(completionValue, expectedValue, assertionInfo); } /** * Checks if the cursor on console input is at expected position. * * @param {WebConsole} hud * @param {Integer} expectedCursorIndex * @param {String} assertionInfo: Description of the assertion passed to `is`. */ function checkInputCursorPosition(hud, expectedCursorIndex, assertionInfo) { const { jsterm } = hud; is(jsterm.editor.getCursor().ch, expectedCursorIndex, assertionInfo); } /** * Checks the console input value and the cursor position given an expected string * containing a "|" to indicate the expected cursor position. * * @param {WebConsole} hud * @param {String} expectedStringWithCursor: * String with a "|" to indicate the expected cursor position. * For example, this is how you assert an empty value with the focus "|", * and this indicates the value should be "test" and the cursor at the * end of the input: "test|". * @param {String} assertionInfo: Description of the assertion passed to `is`. */ function checkInputValueAndCursorPosition( hud, expectedStringWithCursor, assertionInfo ) { info(`Checking jsterm state: \n${expectedStringWithCursor}`); if (!expectedStringWithCursor.includes("|")) { ok( false, `expectedStringWithCursor must contain a "|" char to indicate cursor position` ); } const inputValue = expectedStringWithCursor.replace("|", ""); const { jsterm } = hud; is(getInputValue(hud), inputValue, "console input has expected value"); const lines = expectedStringWithCursor.split("\n"); const lineWithCursor = lines.findIndex(line => line.includes("|")); const { ch, line } = jsterm.editor.getCursor(); is(line, lineWithCursor, assertionInfo + " - correct line"); is(ch, lines[lineWithCursor].indexOf("|"), assertionInfo + " - correct ch"); } /** * Returns the console input completion value. * * @param {WebConsole} hud * @returns {String} */ function getInputCompletionValue(hud) { const { jsterm } = hud; return jsterm.editor.getAutoCompletionText(); } function closeAutocompletePopup(hud) { const { jsterm } = hud; if (!jsterm.autocompletePopup.isOpen) { return Promise.resolve(); } const onPopupClosed = jsterm.autocompletePopup.once("popup-closed"); const onAutocompleteUpdated = jsterm.once("autocomplete-updated"); EventUtils.synthesizeKey("KEY_Escape"); return Promise.all([onPopupClosed, onAutocompleteUpdated]); } /** * Returns a boolean indicating if the console input is focused. * * @param {WebConsole} hud * @returns {Boolean} */ function isInputFocused(hud) { const { jsterm } = hud; const document = hud.ui.outputNode.ownerDocument; const documentIsFocused = document.hasFocus(); return documentIsFocused && jsterm.editor.hasFocus(); } /** * Open the JavaScript debugger. * * @param object options * Options for opening the debugger: * - tab: the tab you want to open the debugger for. * @return object * A promise that is resolved once the debugger opens, or rejected if * the open fails. The resolution callback is given one argument, an * object that holds the following properties: * - target: the Target object for the Tab. * - toolbox: the Toolbox instance. * - panel: the jsdebugger panel instance. */ async function openDebugger(options = {}) { if (!options.tab) { options.tab = gBrowser.selectedTab; } let toolbox = await gDevTools.getToolboxForTab(options.tab); const dbgPanelAlreadyOpen = toolbox && toolbox.getPanel("jsdebugger"); if (dbgPanelAlreadyOpen) { await toolbox.selectTool("jsdebugger"); return { target: toolbox.target, toolbox, panel: toolbox.getCurrentPanel(), }; } toolbox = await gDevTools.showToolboxForTab(options.tab, { toolId: "jsdebugger", }); const panel = toolbox.getCurrentPanel(); await toolbox.threadFront.getSources(); return { target: toolbox.target, toolbox, panel }; } async function openInspector(options = {}) { if (!options.tab) { options.tab = gBrowser.selectedTab; } const toolbox = await gDevTools.showToolboxForTab(options.tab, { toolId: "inspector", }); return toolbox.getCurrentPanel(); } /** * Open the netmonitor for the given tab, or the current one if none given. * * @param Element tab * Optional tab element for which you want open the netmonitor. * Defaults to current selected tab. * @return Promise * A promise that is resolved with the netmonitor panel once the netmonitor is open. */ async function openNetMonitor(tab) { tab = tab || gBrowser.selectedTab; let toolbox = await gDevTools.getToolboxForTab(tab); if (!toolbox) { toolbox = await gDevTools.showToolboxForTab(tab); } await toolbox.selectTool("netmonitor"); return toolbox.getCurrentPanel(); } /** * Open the Web Console for the given tab, or the current one if none given. * * @param Element tab * Optional tab element for which you want open the Web Console. * Defaults to current selected tab. * @return Promise * A promise that is resolved with the console hud once the web console is open. */ async function openConsole(tab) { tab = tab || gBrowser.selectedTab; const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: "webconsole", }); return toolbox.getCurrentPanel().hud; } /** * Close the Web Console for the given tab. * * @param Element [tab] * Optional tab element for which you want close the Web Console. * Defaults to current selected tab. * @return object * A promise that is resolved once the web console is closed. */ async function closeConsole(tab = gBrowser.selectedTab) { const toolbox = await gDevTools.getToolboxForTab(tab); if (toolbox) { await toolbox.destroy(); } } /** * Open a network request logged in the webconsole in the netmonitor panel. * * @param {Object} toolbox * @param {Object} hud * @param {String} url * URL of the request as logged in the netmonitor. * @param {String} urlInConsole * (optional) Use if the logged URL in webconsole is different from the real URL. */ async function openMessageInNetmonitor(toolbox, hud, url, urlInConsole) { // By default urlInConsole should be the same as the complete url. urlInConsole = urlInConsole || url; const message = await waitFor(() => findMessageByType(hud, urlInConsole, ".network") ); const onNetmonitorSelected = toolbox.once( "netmonitor-selected", (event, panel) => { return panel; } ); const menuPopup = await openContextMenu(hud, message); const openInNetMenuItem = menuPopup.querySelector( "#console-menu-open-in-network-panel" ); ok(openInNetMenuItem, "open in network panel item is enabled"); menuPopup.activateItem(openInNetMenuItem); const { panelWin } = await onNetmonitorSelected; ok( true, "The netmonitor panel is selected when clicking on the network message" ); const { store, windowRequire } = panelWin; const nmActions = windowRequire( "devtools/client/netmonitor/src/actions/index" ); const { getSelectedRequest } = windowRequire( "devtools/client/netmonitor/src/selectors/index" ); store.dispatch(nmActions.batchEnable(false)); await waitFor(() => { const selected = getSelectedRequest(store.getState()); return selected && selected.url === url; }, `network entry for the URL "${url}" wasn't found`); ok(true, "The attached url is correct."); info( "Wait for the netmonitor headers panel to appear as it spawns RDP requests" ); await waitFor(() => panelWin.document.querySelector("#headers-panel .headers-overview") ); } function selectNode(hud, node) { const outputContainer = hud.ui.outputNode.querySelector(".webconsole-output"); // We must first blur the input or else we can't select anything. outputContainer.ownerDocument.activeElement.blur(); const selection = outputContainer.ownerDocument.getSelection(); const range = document.createRange(); range.selectNodeContents(node); selection.removeAllRanges(); selection.addRange(range); return selection; } async function waitForBrowserConsole() { return new Promise(resolve => { Services.obs.addObserver(function observer(subject) { Services.obs.removeObserver(observer, "web-console-created"); subject.QueryInterface(Ci.nsISupportsString); const hud = BrowserConsoleManager.getBrowserConsole(); ok(hud, "browser console is open"); is(subject.data, hud.hudId, "notification hudId is correct"); executeSoon(() => resolve(hud)); }, "web-console-created"); }); } /** * Get the state of a console filter. * * @param {Object} hud */ async function getFilterState(hud) { const { outputNode } = hud.ui; const filterBar = outputNode.querySelector(".webconsole-filterbar-secondary"); const buttons = filterBar.querySelectorAll("button"); const result = {}; for (const button of buttons) { result[button.dataset.category] = button.getAttribute("aria-pressed") === "true"; } return result; } /** * Return the filter input element. * * @param {Object} hud * @return {HTMLInputElement} */ function getFilterInput(hud) { return hud.ui.outputNode.querySelector(".devtools-searchbox input"); } /** * Set the state of a console filter. * * @param {Object} hud * @param {Object} settings * Category settings in the following format: * { * error: true, * warn: true, * log: true, * info: true, * debug: true, * css: false, * netxhr: false, * net: false, * text: "" * } */ async function setFilterState(hud, settings) { const { outputNode } = hud.ui; const filterBar = outputNode.querySelector(".webconsole-filterbar-secondary"); for (const category in settings) { const value = settings[category]; const button = filterBar.querySelector(`[data-category="${category}"]`); if (category === "text") { const filterInput = getFilterInput(hud); filterInput.focus(); filterInput.select(); const win = outputNode.ownerDocument.defaultView; if (!value) { EventUtils.synthesizeKey("KEY_Delete", {}, win); } else { EventUtils.sendString(value, win); } await waitFor(() => filterInput.value === value); continue; } if (!button) { ok( false, `setFilterState() called with a category of ${category}, ` + `which doesn't exist.` ); } info( `Setting the ${category} category to ${value ? "checked" : "disabled"}` ); const isPressed = button.getAttribute("aria-pressed"); if ((!value && isPressed === "true") || (value && isPressed !== "true")) { button.click(); await waitFor(() => { const pressed = button.getAttribute("aria-pressed"); if (!value) { return pressed === "false" || pressed === null; } return pressed === "true"; }); } } } /** * Reset the filters at the end of a test that has changed them. This is * important when using the `--verify` test option as when it is used you need * to manually reset the filters. * * The css, netxhr and net filters are disabled by default. * * @param {Object} hud */ async function resetFilters(hud) { info("Resetting filters to their default state"); const store = hud.ui.wrapper.getStore(); store.dispatch(wcActions.filtersClear()); } /** * Open the reverse search input by simulating the appropriate keyboard shortcut. * * @param {Object} hud * @returns {DOMNode} The reverse search dom node. */ async function openReverseSearch(hud) { info("Open the reverse search UI with a keyboard shortcut"); const onReverseSearchUiOpen = waitFor(() => getReverseSearchElement(hud)); const isMacOS = AppConstants.platform === "macosx"; if (isMacOS) { EventUtils.synthesizeKey("r", { ctrlKey: true }); } else { EventUtils.synthesizeKey("VK_F9"); } const element = await onReverseSearchUiOpen; return element; } function getReverseSearchElement(hud) { const { outputNode } = hud.ui; return outputNode.querySelector(".reverse-search"); } function getReverseSearchInfoElement(hud) { const reverseSearchElement = getReverseSearchElement(hud); if (!reverseSearchElement) { return null; } return reverseSearchElement.querySelector(".reverse-search-info"); } /** * Returns a boolean indicating if the reverse search input is focused. * * @param {WebConsole} hud * @returns {Boolean} */ function isReverseSearchInputFocused(hud) { const { outputNode } = hud.ui; const document = outputNode.ownerDocument; const documentIsFocused = document.hasFocus(); const reverseSearchInput = outputNode.querySelector(".reverse-search-input"); return document.activeElement == reverseSearchInput && documentIsFocused; } function getEagerEvaluationElement(hud) { return hud.ui.outputNode.querySelector(".eager-evaluation-result"); } async function waitForEagerEvaluationResult(hud, text) { await waitUntil(() => { const elem = getEagerEvaluationElement(hud); if (elem) { if (text instanceof RegExp) { return text.test(elem.innerText); } return elem.innerText == text; } return false; }); ok(true, `Got eager evaluation result ${text}`); } // This just makes sure the eager evaluation result disappears. This will pass // even for inputs which eventually have a result because nothing will be shown // while the evaluation happens. Waiting here does make sure that a previous // input was processed and sent down to the server for evaluating. async function waitForNoEagerEvaluationResult(hud) { await waitUntil(() => { const elem = getEagerEvaluationElement(hud); return elem && elem.innerText == ""; }); ok(true, `Eager evaluation result disappeared`); } /** * Selects a node in the inspector. * * @param {Object} toolbox * @param {String} selector: The selector for the node we want to select. */ async function selectNodeWithPicker(toolbox, selector) { const inspector = toolbox.getPanel("inspector"); const onPickerStarted = toolbox.nodePicker.once("picker-started"); toolbox.nodePicker.start(); await onPickerStarted; info( `Picker mode started, now clicking on "${selector}" to select that node` ); const onPickerStopped = toolbox.nodePicker.once("picker-stopped"); const onInspectorUpdated = inspector.once("inspector-updated"); await safeSynthesizeMouseEventAtCenterInContentPage(selector); await onPickerStopped; await onInspectorUpdated; } /** * Clicks on the arrow of a single object inspector node if it exists. * * @param {HTMLElement} node: Object inspector node (.tree-node) */ function expandObjectInspectorNode(node) { const arrow = getObjectInspectorNodeArrow(node); if (!arrow) { ok(false, "Node can't be expanded"); return; } arrow.click(); } /** * Retrieve the arrow of a single object inspector node. * * @param {HTMLElement} node: Object inspector node (.tree-node) * @return {HTMLElement|null} the arrow element */ function getObjectInspectorNodeArrow(node) { return node.querySelector(".arrow"); } /** * Check if a single object inspector node is expandable. * * @param {HTMLElement} node: Object inspector node (.tree-node) * @return {Boolean} true if the node can be expanded */ function isObjectInspectorNodeExpandable(node) { return !!getObjectInspectorNodeArrow(node); } /** * Retrieve the nodes for a given object inspector element. * * @param {HTMLElement} oi: Object inspector element * @return {NodeList} the object inspector nodes */ function getObjectInspectorNodes(oi) { return oi.querySelectorAll(".tree-node"); } /** * Retrieve the "children" nodes for a given object inspector node. * * @param {HTMLElement} node: Object inspector node (.tree-node) * @return {Array} the direct children (i.e. the ones that are one level * deeper than the passed node) */ function getObjectInspectorChildrenNodes(node) { const getLevel = n => parseInt(n.getAttribute("aria-level"), 10); const level = getLevel(node); const childLevel = level + 1; const children = []; let currentNode = node; while ( currentNode.nextSibling && getLevel(currentNode.nextSibling) === childLevel ) { currentNode = currentNode.nextSibling; children.push(currentNode); } return children; } /** * Retrieve the invoke getter button for a given object inspector node. * * @param {HTMLElement} node: Object inspector node (.tree-node) * @return {HTMLElement|null} the invoke button element */ function getObjectInspectorInvokeGetterButton(node) { return node.querySelector(".invoke-getter"); } /** * Retrieve the first node that match the passed node label, for a given object inspector * element. * * @param {HTMLElement} oi: Object inspector element * @param {String} nodeLabel: label of the searched node * @return {HTMLElement|null} the Object inspector node with the matching label */ function findObjectInspectorNode(oi, nodeLabel) { return [...oi.querySelectorAll(".tree-node")].find(node => { const label = node.querySelector(".object-label"); if (!label) { return false; } return label.textContent === nodeLabel; }); } /** * Return an array of the label of the autocomplete popup items. * * @param {AutocompletPopup} popup * @returns {Array} */ function getAutocompletePopupLabels(popup) { return popup.getItems().map(item => item.label); } /** * Check if the retrieved list of autocomplete labels of the specific popup * includes all of the expected labels. * * @param {AutocompletPopup} popup * @param {Array} expected the array of expected labels */ function hasExactPopupLabels(popup, expected) { return hasPopupLabels(popup, expected, true); } /** * Check if the expected label is included in the list of autocomplete labels * of the specific popup. * * @param {AutocompletPopup} popup * @param {String} label the label to check */ function hasPopupLabel(popup, label) { return hasPopupLabels(popup, [label]); } /** * Validate the expected labels against the autocomplete labels. * * @param {AutocompletPopup} popup * @param {Array} expectedLabels * @param {Boolean} checkAll */ function hasPopupLabels(popup, expectedLabels, checkAll = false) { const autocompleteLabels = getAutocompletePopupLabels(popup); if (checkAll) { return ( autocompleteLabels.length === expectedLabels.length && autocompleteLabels.every((autoLabel, idx) => { return expectedLabels.indexOf(autoLabel) === idx; }) ); } return expectedLabels.every(expectedLabel => { return autocompleteLabels.includes(expectedLabel); }); } /** * Return the "Confirm Dialog" element. * * @param toolbox * @returns {HTMLElement|null} */ function getConfirmDialog(toolbox) { const { doc } = toolbox; return doc.querySelector(".invoke-confirm"); } /** * Returns true if the Confirm Dialog is opened. * @param toolbox * @returns {Boolean} */ function isConfirmDialogOpened(toolbox) { const tooltip = getConfirmDialog(toolbox); if (!tooltip) { return false; } return tooltip.classList.contains("tooltip-visible"); } async function selectFrame(dbg, frame) { const onScopes = waitForDispatch(dbg.store, "ADD_SCOPES"); await dbg.actions.selectFrame(dbg.selectors.getThreadContext(), frame); await onScopes; } async function pauseDebugger(dbg) { info("Waiting for debugger to pause"); const onPaused = waitForPaused(dbg); SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { content.wrappedJSObject.firstCall(); }).catch(() => {}); await onPaused; } /** * Check that the passed HTMLElement vertically overflows. * @param {HTMLElement} container * @returns {Boolean} */ function hasVerticalOverflow(container) { return container.scrollHeight > container.clientHeight; } /** * Check that the passed HTMLElement is scrolled to the bottom. * @param {HTMLElement} container * @returns {Boolean} */ function isScrolledToBottom(container) { if (!container.lastChild) { return true; } const lastNodeHeight = container.lastChild.clientHeight; return ( container.scrollTop + container.clientHeight >= container.scrollHeight - lastNodeHeight / 2 ); } /** * * @param {WebConsole} hud * @param {Array} expectedMessages: An array of string representing the messages * from the output. This can only be a part of the string of the * message. * Start the string with "▶︎⚠ " or "▼⚠ " to indicate that the * message is a warningGroup (with respectively an open or * collapsed arrow). * Start the string with "|︎ " to indicate that the message is * inside a group and should be indented. */ async function checkConsoleOutputForWarningGroup(hud, expectedMessages) { const messages = await findAllMessagesVirtualized(hud); is( messages.length, expectedMessages.length, "Got the expected number of messages" ); const isInWarningGroup = index => { const message = expectedMessages[index]; if (!message.startsWith("|")) { return false; } const groups = expectedMessages .slice(0, index) .reverse() .filter(m => !m.startsWith("|")); if (groups.length === 0) { ok(false, "Unexpected structure: an indented message isn't in a group"); } return groups[0].startsWith("▼︎⚠"); }; for (let [i, expectedMessage] of expectedMessages.entries()) { // Refresh the reference to the message, as it may have been scrolled out of existence. const message = await findMessageVirtualizedById({ hud, messageId: messages[i].getAttribute("data-message-id"), }); info(`Checking "${expectedMessage}"`); // Collapsed Warning group if (expectedMessage.startsWith("▶︎⚠")) { is( message.querySelector(".arrow").getAttribute("aria-expanded"), "false", "There's a collapsed arrow" ); is( message.getAttribute("data-indent"), "0", "The warningGroup has the expected indent" ); expectedMessage = expectedMessage.replace("▶︎⚠ ", ""); } // Expanded Warning group if (expectedMessage.startsWith("▼︎⚠")) { is( message.querySelector(".arrow").getAttribute("aria-expanded"), "true", "There's an expanded arrow" ); is( message.getAttribute("data-indent"), "0", "The warningGroup has the expected indent" ); expectedMessage = expectedMessage.replace("▼︎⚠ ", ""); } // Collapsed console.group if (expectedMessage.startsWith("▶︎")) { is( message.querySelector(".arrow").getAttribute("aria-expanded"), "false", "There's a collapsed arrow" ); expectedMessage = expectedMessage.replace("▶︎ ", ""); } // Expanded console.group if (expectedMessage.startsWith("▼")) { is( message.querySelector(".arrow").getAttribute("aria-expanded"), "true", "There's an expanded arrow" ); expectedMessage = expectedMessage.replace("▼ ", ""); } // In-group message if (expectedMessage.startsWith("|")) { if (isInWarningGroup(i)) { ok( message.querySelector(".warning-indent"), "The message has the expected indent" ); } expectedMessage = expectedMessage.replace("| ", ""); } else { is( message.getAttribute("data-indent"), "0", "The message has the expected indent" ); } ok( message.textContent.trim().includes(expectedMessage.trim()), `Message includes ` + `the expected "${expectedMessage}" content - "${message.textContent.trim()}"` ); } } /** * Check that there is a message with the specified text that has the specified * stack information. Self-hosted frames are ignored. * @param {WebConsole} hud * @param {string} text * message substring to look for * @param {Array} expectedFrameLines * line numbers of the frames expected in the stack */ async function checkMessageStack(hud, text, expectedFrameLines) { info(`Checking message stack for "${text}"`); const msgNode = await waitFor( () => findErrorMessage(hud, text), `Couln't find message including "${text}"` ); ok(!msgNode.classList.contains("open"), `Error logged not expanded`); const button = await waitFor( () => msgNode.querySelector(".collapse-button"), `Couldn't find the expand button on "${text}" message` ); button.click(); const framesNode = await waitFor( () => msgNode.querySelector(".message-body-wrapper > .stacktrace .frames"), `Couldn't find stacktrace frames on "${text}" message` ); const frameNodes = Array.from(framesNode.querySelectorAll(".frame")).filter( el => { const fileName = el.querySelector(".filename").textContent; return ( fileName !== "self-hosted" && !fileName.startsWith("chrome:") && !fileName.startsWith("resource:") ); } ); for (let i = 0; i < frameNodes.length; i++) { const frameNode = frameNodes[i]; is( frameNode.querySelector(".line").textContent, expectedFrameLines[i].toString(), `Found line ${expectedFrameLines[i]} for frame #${i}` ); } is( frameNodes.length, expectedFrameLines.length, `Found ${frameNodes.length} frames` ); } /** * Reload the content page. * @returns {Promise} A promise that will return when the page is fully loaded (i.e., the * `load` event was fired). */ function reloadPage() { const onLoad = BrowserTestUtils.waitForContentEvent( gBrowser.selectedBrowser, "load", true ); SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { content.location.reload(); }); return onLoad; } /** * Check if the editor mode is enabled (i.e. .webconsole-app has the expected class). * * @param {WebConsole} hud * @returns {Boolean} */ function isEditorModeEnabled(hud) { const { outputNode } = hud.ui; const appNode = outputNode.querySelector(".webconsole-app"); return appNode.classList.contains("jsterm-editor"); } /** * Toggle the layout between in-line and editor. * * @param {WebConsole} hud * @returns {Promise} A promise that resolves once the layout change was rendered. */ function toggleLayout(hud) { const isMacOS = Services.appinfo.OS === "Darwin"; const enabled = isEditorModeEnabled(hud); EventUtils.synthesizeKey("b", { [isMacOS ? "metaKey" : "ctrlKey"]: true, }); return waitFor(() => isEditorModeEnabled(hud) === !enabled); } /** * Wait until all lazily fetch requests in netmonitor get finished. * Otherwise test will be shutdown too early and cause failure. */ async function waitForLazyRequests(toolbox) { const ui = toolbox.getCurrentPanel().hud.ui; return waitUntil(() => { return ( !ui.networkDataProvider.lazyRequestData.size && // Make sure that batched request updates are all complete // as they trigger late lazy data requests. !ui.wrapper.queuedRequestUpdates.length ); }); } /** * Clear the console output and wait for eventual object actors to be released. * * @param {WebConsole} hud * @param {Object} An options object with the following properties: * - {Boolean} keepStorage: true to prevent clearing the messages storage. */ async function clearOutput(hud, { keepStorage = false } = {}) { const { ui } = hud; const promises = [ui.once("messages-cleared")]; // If there's an object inspector, we need to wait for the actors to be released. if (ui.outputNode.querySelector(".object-inspector")) { promises.push(ui.once("fronts-released")); } ui.clearOutput(!keepStorage); await Promise.all(promises); } /** * Retrieve all the items of the context selector menu. * * @param {WebConsole} hud * @return Array */ function getContextSelectorItems(hud) { const toolbox = hud.toolbox; const doc = toolbox ? toolbox.doc : hud.chromeWindow.document; const list = doc.getElementById( "webconsole-console-evaluation-context-selector-menu-list" ); return Array.from(list.querySelectorAll("li.menuitem button, hr")); } /** * Check that the evaluation context selector menu has the expected item, in the expected * state. * * @param {WebConsole} hud * @param {Array} expected: An array of object (see checkContextSelectorMenuItemAt * for expected properties) */ function checkContextSelectorMenu(hud, expected) { const items = getContextSelectorItems(hud); is( items.length, expected.length, "The context selector menu has the expected number of items" ); expected.forEach((expectedItem, i) => { checkContextSelectorMenuItemAt(hud, i, expectedItem); }); } /** * Check that the evaluation context selector menu has the expected item at the specified index. * * @param {WebConsole} hud * @param {Number} index * @param {Object} expected * @param {String} expected.label: The label of the target * @param {String} expected.tooltip: The tooltip of the target element in the menu * @param {Boolean} expected.checked: if the target should be selected or not * @param {Boolean} expected.separator: if the element is a simple separator */ function checkContextSelectorMenuItemAt(hud, index, expected) { const el = getContextSelectorItems(hud).at(index); if (expected.separator === true) { is(el.getAttribute("role"), "menuseparator", "The element is a separator"); return; } const elChecked = el.getAttribute("aria-checked") === "true"; const elTooltip = el.getAttribute("title"); const elLabel = el.querySelector(".label").innerText; is(elLabel, expected.label, `The item has the expected label`); is(elTooltip, expected.tooltip, `Item "${elLabel}" has the expected tooltip`); is( elChecked, expected.checked, `Item "${elLabel}" is ${expected.checked ? "checked" : "unchecked"}` ); } /** * Select a target in the context selector. * * @param {WebConsole} hud * @param {String} targetLabel: The label of the target to select. */ function selectTargetInContextSelector(hud, targetLabel) { const items = getContextSelectorItems(hud); const itemToSelect = items.find( item => item.querySelector(".label")?.innerText === targetLabel ); if (!itemToSelect) { ok(false, `Couldn't find target with "${targetLabel}" label`); return; } itemToSelect.click(); } /** * A helper that returns the size of the image that was just put into the clipboard by the * :screenshot command. * @return The {width, height} dimension object. */ async function getImageSizeFromClipboard() { const clipid = Ci.nsIClipboard; const clip = Cc["@mozilla.org/widget/clipboard;1"].getService(clipid); const trans = Cc["@mozilla.org/widget/transferable;1"].createInstance( Ci.nsITransferable ); const flavor = "image/png"; trans.init(null); trans.addDataFlavor(flavor); clip.getData(trans, clipid.kGlobalClipboard); const data = {}; trans.getTransferData(flavor, data); ok(data.value, "screenshot exists"); let image = data.value; // Due to the differences in how images could be stored in the clipboard the // checks below are needed. The clipboard could already provide the image as // byte streams or as image container. If it's not possible obtain a // byte stream, the function throws. if (image instanceof Ci.imgIContainer) { image = Cc["@mozilla.org/image/tools;1"] .getService(Ci.imgITools) .encodeImage(image, flavor); } if (!(image instanceof Ci.nsIInputStream)) { throw new Error("Unable to read image data"); } const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( Ci.nsIBinaryInputStream ); binaryStream.setInputStream(image); const available = binaryStream.available(); const buffer = new ArrayBuffer(available); is( binaryStream.readArrayBuffer(available, buffer), available, "Read expected amount of data" ); // We are going to load the image in the content page to measure its size. // We don't want to insert the image directly in the browser's document // (which is value of the global `document` here). Doing so might push the // toolbox upwards, shrink the content page and fail the fullpage screenshot // test. return SpecialPowers.spawn( gBrowser.selectedBrowser, [buffer], async function (_buffer) { const img = content.document.createElement("img"); const loaded = new Promise(r => { img.addEventListener("load", r, { once: true }); }); // Build a URL from the buffer passed to the ContentTask const url = content.URL.createObjectURL( new Blob([_buffer], { type: "image/png" }) ); // Load the image img.src = url; content.document.documentElement.appendChild(img); info("Waiting for the clipboard image to load in the content page"); await loaded; // Remove the image and revoke the URL. img.remove(); content.URL.revokeObjectURL(url); return { width: img.width, height: img.height, }; } ); }