/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const {Cc, Ci, Cu} = require("chrome"); const {Utils: WebConsoleUtils, CONSOLE_WORKER_IDS} = require("devtools/client/webconsole/utils"); const { getSourceNames } = require("devtools/client/shared/source-utils"); const BrowserLoaderModule = {}; Cu.import("resource://devtools/client/shared/browser-loader.js", BrowserLoaderModule); const promise = require("promise"); const Services = require("Services"); const ErrorDocs = require("devtools/server/actors/errordocs"); const Telemetry = require("devtools/client/shared/telemetry"); loader.lazyServiceGetter(this, "clipboardHelper", "@mozilla.org/widget/clipboardhelper;1", "nsIClipboardHelper"); loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); loader.lazyRequireGetter(this, "AutocompletePopup", "devtools/client/shared/autocomplete-popup", true); loader.lazyRequireGetter(this, "ToolSidebar", "devtools/client/framework/sidebar", true); loader.lazyRequireGetter(this, "ConsoleOutput", "devtools/client/webconsole/console-output", true); loader.lazyRequireGetter(this, "Messages", "devtools/client/webconsole/console-output", true); loader.lazyRequireGetter(this, "EnvironmentClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "ObjectClient", "devtools/shared/client/main", true); loader.lazyRequireGetter(this, "system", "devtools/shared/system"); loader.lazyRequireGetter(this, "JSTerm", "devtools/client/webconsole/jsterm", true); loader.lazyRequireGetter(this, "gSequenceId", "devtools/client/webconsole/jsterm", true); loader.lazyImporter(this, "VariablesView", "resource://devtools/client/shared/widgets/VariablesView.jsm"); loader.lazyImporter(this, "VariablesViewController", "resource://devtools/client/shared/widgets/VariablesViewController.jsm"); loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); loader.lazyRequireGetter(this, "KeyShortcuts", "devtools/client/shared/key-shortcuts", true); loader.lazyRequireGetter(this, "ZoomKeys", "devtools/client/shared/zoom-keys"); const {PluralForm} = require("devtools/shared/plural-form"); const STRINGS_URI = "devtools/client/locales/webconsole.properties"; var l10n = new WebConsoleUtils.L10n(STRINGS_URI); const XHTML_NS = "http://www.w3.org/1999/xhtml"; const MIXED_CONTENT_LEARN_MORE = "https://developer.mozilla.org/docs/Web/Security/Mixed_content"; const IGNORED_SOURCE_URLS = ["debugger eval code"]; // The amount of time in milliseconds that we wait before performing a live // search. const SEARCH_DELAY = 200; // The number of lines that are displayed in the console output by default, for // each category. The user can change this number by adjusting the hidden // "devtools.hud.loglimit.{network,cssparser,exception,console}" preferences. const DEFAULT_LOG_LIMIT = 1000; // The various categories of messages. We start numbering at zero so we can // use these as indexes into the MESSAGE_PREFERENCE_KEYS matrix below. const CATEGORY_NETWORK = 0; const CATEGORY_CSS = 1; const CATEGORY_JS = 2; const CATEGORY_WEBDEV = 3; // always on const CATEGORY_INPUT = 4; // always on const CATEGORY_OUTPUT = 5; const CATEGORY_SECURITY = 6; const CATEGORY_SERVER = 7; // The possible message severities. As before, we start at zero so we can use // these as indexes into MESSAGE_PREFERENCE_KEYS. const SEVERITY_ERROR = 0; const SEVERITY_WARNING = 1; const SEVERITY_INFO = 2; const SEVERITY_LOG = 3; // The fragment of a CSS class name that identifies each category. const CATEGORY_CLASS_FRAGMENTS = [ "network", "cssparser", "exception", "console", "input", "output", "security", "server", ]; // The fragment of a CSS class name that identifies each severity. const SEVERITY_CLASS_FRAGMENTS = [ "error", "warn", "info", "log", ]; // The preference keys to use for each category/severity combination, indexed // first by category (rows) and then by severity (columns) in the following // order: // // [ Error, Warning, Info, Log ] // // Most of these rather idiosyncratic names are historical and predate the // division of message type into "category" and "severity". const MESSAGE_PREFERENCE_KEYS = [ // Network [ "network", "netwarn", "netxhr", "networkinfo", ], // CSS [ "csserror", "cssparser", null, "csslog", ], // JS [ "exception", "jswarn", null, "jslog", ], // Web Developer [ "error", "warn", "info", "log", ], // Input [ null, null, null, null, ], // Output [ null, null, null, null, ], // Security [ "secerror", "secwarn", null, null, ], // Server Logging [ "servererror", "serverwarn", "serverinfo", "serverlog", ], ]; // A mapping from the console API log event levels to the Web Console // severities. const LEVELS = { error: SEVERITY_ERROR, exception: SEVERITY_ERROR, assert: SEVERITY_ERROR, warn: SEVERITY_WARNING, info: SEVERITY_INFO, log: SEVERITY_LOG, clear: SEVERITY_LOG, trace: SEVERITY_LOG, table: SEVERITY_LOG, debug: SEVERITY_LOG, dir: SEVERITY_LOG, dirxml: SEVERITY_LOG, group: SEVERITY_LOG, groupCollapsed: SEVERITY_LOG, groupEnd: SEVERITY_LOG, time: SEVERITY_LOG, timeEnd: SEVERITY_LOG, count: SEVERITY_LOG }; // This array contains the prefKey for the workers and it must keep them in the // same order as CONSOLE_WORKER_IDS const WORKERTYPES_PREFKEYS = [ "sharedworkers", "serviceworkers", "windowlessworkers" ]; // The lowest HTTP response code (inclusive) that is considered an error. const MIN_HTTP_ERROR_CODE = 400; // The highest HTTP response code (inclusive) that is considered an error. const MAX_HTTP_ERROR_CODE = 599; // The indent of a console group in pixels. const GROUP_INDENT = 12; // The number of messages to display in a single display update. If we display // too many messages at once we slow down the Firefox UI too much. const MESSAGES_IN_INTERVAL = DEFAULT_LOG_LIMIT; // The delay (in milliseconds) between display updates - tells how often we // should *try* to push new messages to screen. This value is optimistic, // updates won't always happen. Keep this low so the Web Console output feels // live. const OUTPUT_INTERVAL = 20; // The maximum amount of time (in milliseconds) that can be spent doing cleanup // inside of the flush output callback. If things don't get cleaned up in this // time, then it will start again the next time it is called. const MAX_CLEANUP_TIME = 10; // When the output queue has more than MESSAGES_IN_INTERVAL items we throttle // output updates to this number of milliseconds. So during a lot of output we // update every N milliseconds given here. const THROTTLE_UPDATES = 1000; // The preference prefix for all of the Web Console filters. const FILTER_PREFS_PREFIX = "devtools.webconsole.filter."; // The minimum font size. const MIN_FONT_SIZE = 10; const PREF_CONNECTION_TIMEOUT = "devtools.debugger.remote-timeout"; const PREF_PERSISTLOG = "devtools.webconsole.persistlog"; const PREF_MESSAGE_TIMESTAMP = "devtools.webconsole.timestampMessages"; const PREF_NEW_FRONTEND_ENABLED = "devtools.webconsole.new-frontend-enabled"; /** * A WebConsoleFrame instance is an interactive console initialized *per target* * that displays console log data as well as provides an interactive terminal to * manipulate the target's document content. * * The WebConsoleFrame is responsible for the actual Web Console UI * implementation. * * @constructor * @param object webConsoleOwner * The WebConsole owner object. */ function WebConsoleFrame(webConsoleOwner) { this.owner = webConsoleOwner; this.hudId = this.owner.hudId; this.isBrowserConsole = this.owner._browserConsole; this.window = this.owner.iframeWindow; this._repeatNodes = {}; this._outputQueue = []; this._itemDestroyQueue = []; this._pruneCategoriesQueue = {}; this.filterPrefs = {}; this.output = new ConsoleOutput(this); this.unmountMessage = this.unmountMessage.bind(this); this._toggleFilter = this._toggleFilter.bind(this); this.resize = this.resize.bind(this); this._onPanelSelected = this._onPanelSelected.bind(this); this._flushMessageQueue = this._flushMessageQueue.bind(this); this._onToolboxPrefChanged = this._onToolboxPrefChanged.bind(this); this._onUpdateListeners = this._onUpdateListeners.bind(this); this._outputTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this._outputTimerInitialized = false; let require = BrowserLoaderModule.BrowserLoader({ window: this.window, useOnlyShared: true }).require; this.React = require("devtools/client/shared/vendor/react"); this.ReactDOM = require("devtools/client/shared/vendor/react-dom"); this.FrameView = this.React.createFactory(require("devtools/client/shared/components/frame")); this.StackTraceView = this.React.createFactory(require("devtools/client/shared/components/stack-trace")); this._telemetry = new Telemetry(); EventEmitter.decorate(this); } exports.WebConsoleFrame = WebConsoleFrame; WebConsoleFrame.prototype = { /** * The WebConsole instance that owns this frame. * @see hudservice.js::WebConsole * @type object */ owner: null, /** * Proxy between the Web Console and the remote Web Console instance. This * object holds methods used for connecting, listening and disconnecting from * the remote server, using the remote debugging protocol. * * @see WebConsoleConnectionProxy * @type object */ proxy: null, /** * Getter for the xul:popupset that holds any popups we open. * @type nsIDOMElement */ get popupset() { return this.owner.mainPopupSet; }, /** * Holds the initialization promise object. * @private * @type object */ _initDefer: null, /** * Last time when we displayed any message in the output. * * @private * @type number * Timestamp in milliseconds since the Unix epoch. */ _lastOutputFlush: 0, /** * Message nodes are stored here in a queue for later display. * * @private * @type array */ _outputQueue: null, /** * Keep track of the categories we need to prune from time to time. * * @private * @type array */ _pruneCategoriesQueue: null, /** * Function invoked whenever the output queue is emptied. This is used by some * tests. * * @private * @type function */ _flushCallback: null, /** * Timer used for flushing the messages output queue. * * @private * @type nsITimer */ _outputTimer: null, _outputTimerInitialized: null, /** * Store for tracking repeated nodes. * @private * @type object */ _repeatNodes: null, /** * Preferences for filtering messages by type. * @see this._initDefaultFilterPrefs() * @type object */ filterPrefs: null, /** * Prefix used for filter preferences. * @private * @type string */ _filterPrefsPrefix: FILTER_PREFS_PREFIX, /** * The nesting depth of the currently active console group. */ groupDepth: 0, /** * The current target location. * @type string */ contentLocation: "", /** * The JSTerm object that manage the console's input. * @see JSTerm * @type object */ jsterm: null, /** * The element that holds all of the messages we display. * @type nsIDOMElement */ outputNode: null, /** * The ConsoleOutput instance that manages all output. * @type object */ output: null, /** * The input element that allows the user to filter messages by string. * @type nsIDOMElement */ filterBox: null, /** * Getter for the debugger WebConsoleClient. * @type object */ get webConsoleClient() { return this.proxy ? this.proxy.webConsoleClient : null; }, _destroyer: null, _saveRequestAndResponseBodies: true, _throttleData: null, // Chevron width at the starting of Web Console's input box. _chevronWidth: 0, // Width of the monospace characters in Web Console's input box. _inputCharWidth: 0, /** * Setter for saving of network request and response bodies. * * @param boolean value * The new value you want to set. */ setSaveRequestAndResponseBodies: function (value) { if (!this.webConsoleClient) { // Don't continue if the webconsole disconnected. return promise.resolve(null); } let deferred = promise.defer(); let newValue = !!value; let toSet = { "NetworkMonitor.saveRequestAndResponseBodies": newValue, }; // Make sure the web console client connection is established first. this.webConsoleClient.setPreferences(toSet, response => { if (!response.error) { this._saveRequestAndResponseBodies = newValue; deferred.resolve(response); } else { deferred.reject(response.error); } }); return deferred.promise; }, /** * Setter for throttling data. * * @param boolean value * The new value you want to set; @see NetworkThrottleManager. */ setThrottleData: function(value) { if (!this.webConsoleClient) { // Don't continue if the webconsole disconnected. return promise.resolve(null); } let deferred = promise.defer(); let toSet = { "NetworkMonitor.throttleData": value, }; // Make sure the web console client connection is established first. this.webConsoleClient.setPreferences(toSet, response => { if (!response.error) { this._throttleData = value; deferred.resolve(response); } else { deferred.reject(response.error); } }); return deferred.promise; }, /** * Getter for the persistent logging preference. * @type boolean */ get persistLog() { // For the browser console, we receive tab navigation // when the original top level window we attached to is closed, // but we don't want to reset console history and just switch to // the next available window. return this.isBrowserConsole || Services.prefs.getBoolPref(PREF_PERSISTLOG); }, /** * Initialize the WebConsoleFrame instance. * @return object * A promise object that resolves once the frame is ready to use. */ init: function () { this._initUI(); let connectionInited = this._initConnection(); // Don't reject if the history fails to load for some reason. // This would be fine, the panel will just start with empty history. let allReady = this.jsterm.historyLoaded.catch(() => {}).then(() => { return connectionInited; }); // This notification is only used in tests. Don't chain it onto // the returned promise because the console panel needs to be attached // to the toolbox before the web-console-created event is receieved. let notifyObservers = () => { let id = WebConsoleUtils.supportsString(this.hudId); Services.obs.notifyObservers(id, "web-console-created", null); }; allReady.then(notifyObservers, notifyObservers); if (this.NEW_CONSOLE_OUTPUT_ENABLED) { allReady.then(this.newConsoleOutput.init); } return allReady; }, /** * Connect to the server using the remote debugging protocol. * * @private * @return object * A promise object that is resolved/reject based on the connection * result. */ _initConnection: function () { if (this._initDefer) { return this._initDefer.promise; } this._initDefer = promise.defer(); this.proxy = new WebConsoleConnectionProxy(this, this.owner.target); this.proxy.connect().then(() => { // on success this._initDefer.resolve(this); }, (reason) => { // on failure let node = this.createMessageNode(CATEGORY_JS, SEVERITY_ERROR, reason.error + ": " + reason.message); this.outputMessage(CATEGORY_JS, node, [reason]); this._initDefer.reject(reason); }); return this._initDefer.promise; }, /** * Find the Web Console UI elements and setup event listeners as needed. * @private */ _initUI: function () { this.document = this.window.document; this.rootElement = this.document.documentElement; this.NEW_CONSOLE_OUTPUT_ENABLED = !this.isBrowserConsole && !this.owner.target.chrome && Services.prefs.getBoolPref(PREF_NEW_FRONTEND_ENABLED); this.outputNode = this.document.getElementById("output-container"); this.outputWrapper = this.document.getElementById("output-wrapper"); this.completeNode = this.document.querySelector(".jsterm-complete-node"); this.inputNode = this.document.querySelector(".jsterm-input-node"); // In the old frontend, the area that scrolls is outputWrapper, but in the new // frontend this will be reassigned. this.outputScroller = this.outputWrapper; // Update the character width and height needed for the popup offset // calculations. this._updateCharSize(); let saveBodiesDisabled = !this.getFilterState("networkinfo") && !this.getFilterState("netxhr") && !this.getFilterState("network"); let saveBodies = this.document.getElementById("saveBodies"); saveBodies.disabled = saveBodiesDisabled; saveBodies.parentNode.addEventListener("popupshowing", () => { saveBodies.disabled = !this.getFilterState("networkinfo") && !this.getFilterState("netxhr") && !this.getFilterState("network"); }); this.jsterm = new JSTerm(this); this.jsterm.init(); let toolbox = gDevTools.getToolbox(this.owner.target); if (this.NEW_CONSOLE_OUTPUT_ENABLED) { // @TODO Remove this once JSTerm is handled with React/Redux. this.window.jsterm = this.jsterm; // Remove context menu for now (see Bug 1307239). this.outputWrapper.removeAttribute("context"); // XXX: We should actually stop output from happening on old output // panel, but for now let's just hide it. this.experimentalOutputNode = this.outputNode.cloneNode(); this.experimentalOutputNode.removeAttribute("tabindex"); this.outputNode.hidden = true; this.outputNode.parentNode.appendChild(this.experimentalOutputNode); // @TODO Once the toolbox has been converted to React, see if passing // in JSTerm is still necessary. this.newConsoleOutput = new this.window.NewConsoleOutput( this.experimentalOutputNode, this.jsterm, toolbox, this.owner, this.document); let filterToolbar = this.document.querySelector(".hud-console-filter-toolbar"); filterToolbar.hidden = true; } else { // Register the controller to handle "select all" properly. this._commandController = new CommandController(this); this.window.controllers.insertControllerAt(0, this._commandController); this._contextMenuHandler = new ConsoleContextMenu(this); this._initDefaultFilterPrefs(); this.filterBox = this.document.querySelector(".hud-filter-box"); this._setFilterTextBoxEvents(); this._initFilterButtons(); let clearButton = this.document.getElementsByClassName("webconsole-clear-console-button")[0]; clearButton.addEventListener("command", () => { this.owner._onClearButton(); this.jsterm.clearOutput(true); }); } this.resize(); this.window.addEventListener("resize", this.resize, true); this.jsterm.on("sidebar-opened", this.resize); this.jsterm.on("sidebar-closed", this.resize); if (toolbox) { toolbox.on("webconsole-selected", this._onPanelSelected); } /* * Focus the input line whenever the output area is clicked. */ this.outputWrapper.addEventListener("click", (event) => { // Do not focus on middle/right-click or 2+ clicks. if (event.detail !== 1 || event.button !== 0) { return; } // Do not focus if something is selected let selection = this.window.getSelection(); if (selection && !selection.isCollapsed) { return; } // Do not focus if a link was clicked if (event.target.nodeName.toLowerCase() === "a" || event.target.parentNode.nodeName.toLowerCase() === "a") { return; } // Do not focus if a search input was clicked on the new frontend if (this.NEW_CONSOLE_OUTPUT_ENABLED && event.target.nodeName.toLowerCase() === "input" && event.target.getAttribute("type").toLowerCase() === "search") { return; } this.jsterm.focus(); }); // Toggle the timestamp on preference change gDevTools.on("pref-changed", this._onToolboxPrefChanged); this._onToolboxPrefChanged("pref-changed", { pref: PREF_MESSAGE_TIMESTAMP, newValue: Services.prefs.getBoolPref(PREF_MESSAGE_TIMESTAMP), }); this._initShortcuts(); // focus input node this.jsterm.focus(); }, /** * Resizes the output node to fit the output wrapped. * We need this because it makes the layout a lot faster than * using -moz-box-flex and 100% width. See Bug 1237368. */ resize: function () { if (this.NEW_CONSOLE_OUTPUT_ENABLED) { this.experimentalOutputNode.style.width = this.outputWrapper.clientWidth + "px"; } else { this.outputNode.style.width = this.outputWrapper.clientWidth + "px"; } }, /** * Sets the focus to JavaScript input field when the web console tab is * selected or when there is a split console present. * @private */ _onPanelSelected: function () { this.jsterm.focus(); }, /** * Initialize the default filter preferences. * @private */ _initDefaultFilterPrefs: function () { let prefs = ["network", "networkinfo", "csserror", "cssparser", "csslog", "exception", "jswarn", "jslog", "error", "info", "warn", "log", "secerror", "secwarn", "netwarn", "netxhr", "saveBodies", "sharedworkers", "serviceworkers", "windowlessworkers", "servererror", "serverwarn", "serverinfo", "serverlog"]; for (let pref of prefs) { this.filterPrefs[pref] = Services.prefs.getBoolPref( this._filterPrefsPrefix + pref); } }, _initShortcuts: function() { var shortcuts = new KeyShortcuts({ window: this.window }); shortcuts.on(l10n.getStr("webconsole.find.key"), (name, event) => { this.filterBox.focus(); event.preventDefault(); }); let clearShortcut; if (system.constants.platform === "macosx") { clearShortcut = l10n.getStr("webconsole.clear.keyOSX"); } else { clearShortcut = l10n.getStr("webconsole.clear.key"); } shortcuts.on(clearShortcut, () => this.jsterm.clearOutput(true)); if (this.isBrowserConsole) { shortcuts.on(l10n.getStr("webconsole.close.key"), this.window.close.bind(this.window)); ZoomKeys.register(this.window); } }, /** * Attach / detach reflow listeners depending on the checked status * of the `CSS > Log` menuitem. * * @param function [callback=null] * Optional function to invoke when the listener has been * added/removed. */ _updateReflowActivityListener: function (callback) { if (this.webConsoleClient) { let pref = this._filterPrefsPrefix + "csslog"; if (Services.prefs.getBoolPref(pref)) { this.webConsoleClient.startListeners(["ReflowActivity"], callback); } else { this.webConsoleClient.stopListeners(["ReflowActivity"], callback); } } }, /** * Attach / detach server logging listener depending on the filter * preferences. If the user isn't interested in the server logs at * all the listener is not registered. * * @param function [callback=null] * Optional function to invoke when the listener has been * added/removed. */ _updateServerLoggingListener: function (callback) { if (!this.webConsoleClient) { return null; } let startListener = false; let prefs = ["servererror", "serverwarn", "serverinfo", "serverlog"]; for (let i = 0; i < prefs.length; i++) { if (this.filterPrefs[prefs[i]]) { startListener = true; break; } } if (startListener) { this.webConsoleClient.startListeners(["ServerLogging"], callback); } else { this.webConsoleClient.stopListeners(["ServerLogging"], callback); } }, /** * Sets the events for the filter input field. * @private */ _setFilterTextBoxEvents: function () { let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); let timerEvent = this.adjustVisibilityOnSearchStringChange.bind(this); let onChange = function _onChange() { // To improve responsiveness, we let the user finish typing before we // perform the search. timer.cancel(); timer.initWithCallback(timerEvent, SEARCH_DELAY, Ci.nsITimer.TYPE_ONE_SHOT); }; this.filterBox.addEventListener("command", onChange, false); this.filterBox.addEventListener("input", onChange, false); }, /** * Creates one of the filter buttons on the toolbar. * * @private * @param nsIDOMNode aParent * The node to which the filter button should be appended. * @param object aDescriptor * A descriptor that contains info about the button. Contains "name", * "category", and "prefKey" properties, and optionally a "severities" * property. */ _initFilterButtons: function () { let categories = this.document .querySelectorAll(".webconsole-filter-button[category]"); Array.forEach(categories, function (button) { button.addEventListener("contextmenu", () => { button.open = true; }, false); button.addEventListener("click", this._toggleFilter, false); let someChecked = false; let severities = button.querySelectorAll("menuitem[prefKey]"); Array.forEach(severities, function (menuItem) { menuItem.addEventListener("command", this._toggleFilter, false); let prefKey = menuItem.getAttribute("prefKey"); let checked = this.filterPrefs[prefKey]; menuItem.setAttribute("checked", checked); someChecked = someChecked || checked; }, this); button.setAttribute("checked", someChecked); button.setAttribute("aria-pressed", someChecked); }, this); if (!this.isBrowserConsole) { // The Browser Console displays nsIConsoleMessages which are messages that // end up in the JS category, but they are not errors or warnings, they // are just log messages. The Web Console does not show such messages. let jslog = this.document.querySelector("menuitem[prefKey=jslog]"); jslog.hidden = true; } if (Services.appinfo.OS == "Darwin") { let net = this.document.querySelector("toolbarbutton[category=net]"); let accesskey = net.getAttribute("accesskeyMacOSX"); net.setAttribute("accesskey", accesskey); let logging = this.document.querySelector("toolbarbutton[category=logging]"); logging.removeAttribute("accesskey"); let serverLogging = this.document.querySelector("toolbarbutton[category=server]"); serverLogging.removeAttribute("accesskey"); } }, /** * Calculates the width and height of a single character of the input box. * This will be used in opening the popup at the correct offset. * * @private */ _updateCharSize: function () { let doc = this.document; let tempLabel = doc.createElementNS(XHTML_NS, "span"); let style = tempLabel.style; style.position = "fixed"; style.padding = "0"; style.margin = "0"; style.width = "auto"; style.color = "transparent"; WebConsoleUtils.copyTextStyles(this.inputNode, tempLabel); tempLabel.textContent = "x"; doc.documentElement.appendChild(tempLabel); this._inputCharWidth = tempLabel.offsetWidth; tempLabel.parentNode.removeChild(tempLabel); // Calculate the width of the chevron placed at the beginning of the input // box. Remove 4 more pixels to accomodate the padding of the popup. this._chevronWidth = +doc.defaultView.getComputedStyle(this.inputNode) .paddingLeft.replace(/[^0-9.]/g, "") - 4; }, /** * The event handler that is called whenever a user switches a filter on or * off. * * @private * @param nsIDOMEvent event * The event that triggered the filter change. */ _toggleFilter: function (event) { let target = event.target; let tagName = target.tagName; // Prevent toggle if generated from a contextmenu event (right click) let isRightClick = (event.button === 2); if (tagName != event.currentTarget.tagName || isRightClick) { return; } switch (tagName) { case "toolbarbutton": { let originalTarget = event.originalTarget; let classes = originalTarget.classList; if (originalTarget.localName !== "toolbarbutton") { // Oddly enough, the click event is sent to the menu button when // selecting a menu item with the mouse. Detect this case and bail // out. break; } if (!classes.contains("toolbarbutton-menubutton-button") && originalTarget.getAttribute("type") === "menu-button") { // This is a filter button with a drop-down. The user clicked the // drop-down, so do nothing. (The menu will automatically appear // without our intervention.) break; } // Toggle on the targeted filter button, and if the user alt clicked, // toggle off all other filter buttons and their associated filters. let state = target.getAttribute("checked") !== "true"; if (event.getModifierState("Alt")) { let buttons = this.document .querySelectorAll(".webconsole-filter-button"); Array.forEach(buttons, (button) => { if (button !== target) { button.setAttribute("checked", false); button.setAttribute("aria-pressed", false); this._setMenuState(button, false); } }); state = true; } target.setAttribute("checked", state); target.setAttribute("aria-pressed", state); // This is a filter button with a drop-down, and the user clicked the // main part of the button. Go through all the severities and toggle // their associated filters. this._setMenuState(target, state); // CSS reflow logging can decrease web page performance. // Make sure the option is always unchecked when the CSS filter button // is selected. See bug 971798. if (target.getAttribute("category") == "css" && state) { let csslogMenuItem = target.querySelector("menuitem[prefKey=csslog]"); csslogMenuItem.setAttribute("checked", false); this.setFilterState("csslog", false); } break; } case "menuitem": { let state = target.getAttribute("checked") !== "true"; target.setAttribute("checked", state); let prefKey = target.getAttribute("prefKey"); this.setFilterState(prefKey, state); // Disable the log response and request body if network logging is off. if (prefKey == "networkinfo" || prefKey == "netxhr" || prefKey == "network") { let checkState = !this.getFilterState("networkinfo") && !this.getFilterState("netxhr") && !this.getFilterState("network"); this.document.getElementById("saveBodies").disabled = checkState; } // Adjust the state of the button appropriately. let menuPopup = target.parentNode; let someChecked = false; let menuItem = menuPopup.firstChild; while (menuItem) { if (menuItem.hasAttribute("prefKey") && menuItem.getAttribute("checked") === "true") { someChecked = true; break; } menuItem = menuItem.nextSibling; } let toolbarButton = menuPopup.parentNode; toolbarButton.setAttribute("checked", someChecked); toolbarButton.setAttribute("aria-pressed", someChecked); break; } } }, /** * Set the menu attributes for a specific toggle button. * * @private * @param XULElement target * Button with drop down items to be toggled. * @param boolean state * True if the menu item is being toggled on, and false otherwise. */ _setMenuState: function (target, state) { let menuItems = target.querySelectorAll("menuitem"); Array.forEach(menuItems, (item) => { let prefKey = item.getAttribute("prefKey"); // If not a separate switch only. if (prefKey != "saveBodies") { item.setAttribute("checked", state); this.setFilterState(prefKey, state); } }); }, /** * Set the filter state for a specific toggle button. * * @param string toggleType * @param boolean state * @returns void */ setFilterState: function (toggleType, state) { this.filterPrefs[toggleType] = state; this.adjustVisibilityForMessageType(toggleType, state); Services.prefs.setBoolPref(this._filterPrefsPrefix + toggleType, state); if (toggleType == "saveBodies") { this.setSaveRequestAndResponseBodies(state); } if (this._updateListenersTimeout) { clearTimeout(this._updateListenersTimeout); } this._updateListenersTimeout = setTimeout( this._onUpdateListeners, 200); }, /** * Get the filter state for a specific toggle button. * * @param string toggleType * @returns boolean */ getFilterState: function (toggleType) { return this.filterPrefs[toggleType]; }, /** * Called when a logging filter changes. Allows to stop/start * listeners according to the current filter state. */ _onUpdateListeners: function () { this._updateReflowActivityListener(); this._updateServerLoggingListener(); }, /** * Check that the passed string matches the filter arguments. * * @param String str * to search for filter words in. * @param String filter * is a string containing all of the words to filter on. * @returns boolean */ stringMatchesFilters: function (str, filter) { if (!filter || !str) { return true; } let searchStr = str.toLowerCase(); let filterStrings = filter.toLowerCase().split(/\s+/); return !filterStrings.some(function (f) { return searchStr.indexOf(f) == -1; }); }, /** * Turns the display of log nodes on and off appropriately to reflect the * adjustment of the message type filter named by @prefKey. * * @param string prefKey * The preference key for the message type being filtered: one of the * values in the MESSAGE_PREFERENCE_KEYS table. * @param boolean state * True if the filter named by @messageType is being turned on; false * otherwise. * @returns void */ adjustVisibilityForMessageType: function (prefKey, state) { let outputNode = this.outputNode; let doc = this.document; // Look for message nodes (".message") with the given preference key // (filter="error", filter="cssparser", etc.) and add or remove the // "filtered-by-type" class, which turns on or off the display. let attribute = WORKERTYPES_PREFKEYS.indexOf(prefKey) == -1 ? "filter" : "workerType"; let xpath = ".//*[contains(@class, 'message') and " + "@" + attribute + "='" + prefKey + "']"; let result = doc.evaluate(xpath, outputNode, null, Ci.nsIDOMXPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null); for (let i = 0; i < result.snapshotLength; i++) { let node = result.snapshotItem(i); if (state) { node.classList.remove("filtered-by-type"); } else { node.classList.add("filtered-by-type"); } } }, /** * Turns the display of log nodes on and off appropriately to reflect the * adjustment of the search string. */ adjustVisibilityOnSearchStringChange: function () { let nodes = this.outputNode.getElementsByClassName("message"); let searchString = this.filterBox.value; for (let i = 0, n = nodes.length; i < n; ++i) { let node = nodes[i]; // hide nodes that match the strings let text = node.textContent; // if the text matches the words in aSearchString... if (this.stringMatchesFilters(text, searchString)) { node.classList.remove("filtered-by-string"); } else { node.classList.add("filtered-by-string"); } } this.resize(); }, /** * Applies the user's filters to a newly-created message node via CSS * classes. * * @param nsIDOMNode node * The newly-created message node. * @return boolean * True if the message was filtered or false otherwise. */ filterMessageNode: function (node) { let isFiltered = false; // Filter by the message type. let prefKey = MESSAGE_PREFERENCE_KEYS[node.category][node.severity]; if (prefKey && !this.getFilterState(prefKey)) { // The node is filtered by type. node.classList.add("filtered-by-type"); isFiltered = true; } // Filter by worker type if ("workerType" in node && !this.getFilterState(node.workerType)) { node.classList.add("filtered-by-type"); isFiltered = true; } // Filter on the search string. let search = this.filterBox.value; let text = node.clipboardText; // if string matches the filter text if (!this.stringMatchesFilters(text, search)) { node.classList.add("filtered-by-string"); isFiltered = true; } if (isFiltered && node.classList.contains("inlined-variables-view")) { node.classList.add("hidden-message"); } return isFiltered; }, /** * Merge the attributes of repeated nodes. * * @param nsIDOMNode original * The Original Node. The one being merged into. */ mergeFilteredMessageNode: function (original) { let repeatNode = original.getElementsByClassName("message-repeats")[0]; if (!repeatNode) { // no repeat node, return early. return; } let occurrences = parseInt(repeatNode.getAttribute("value"), 10) + 1; repeatNode.setAttribute("value", occurrences); repeatNode.textContent = occurrences; let str = l10n.getStr("messageRepeats.tooltip2"); repeatNode.title = PluralForm.get(occurrences, str) .replace("#1", occurrences); }, /** * Filter the message node from the output if it is a repeat. * * @private * @param nsIDOMNode node * The message node to be filtered or not. * @returns nsIDOMNode|null * Returns the duplicate node if the message was filtered, null * otherwise. */ _filterRepeatedMessage: function (node) { let repeatNode = node.getElementsByClassName("message-repeats")[0]; if (!repeatNode) { return null; } let uid = repeatNode._uid; let dupeNode = null; if (node.category == CATEGORY_CSS || node.category == CATEGORY_SECURITY) { dupeNode = this._repeatNodes[uid]; if (!dupeNode) { this._repeatNodes[uid] = node; } } else if ((node.category == CATEGORY_WEBDEV || node.category == CATEGORY_JS) && node.category != CATEGORY_NETWORK && !node.classList.contains("inlined-variables-view")) { let lastMessage = this.outputNode.lastChild; if (!lastMessage) { return null; } let lastRepeatNode = lastMessage.getElementsByClassName("message-repeats")[0]; if (lastRepeatNode && lastRepeatNode._uid == uid) { dupeNode = lastMessage; } } if (dupeNode) { this.mergeFilteredMessageNode(dupeNode); // Even though this node was never rendered, we create the location // nodes before rendering, so we still have to clean up any // React components this.unmountMessage(node); return dupeNode; } return null; }, /** * Display cached messages that may have been collected before the UI is * displayed. * * @param array remoteMessages * Array of cached messages coming from the remote Web Console * content instance. */ displayCachedMessages: function (remoteMessages) { if (!remoteMessages.length) { return; } remoteMessages.forEach(function (message) { switch (message._type) { case "PageError": { let category = Utils.categoryForScriptError(message); this.outputMessage(category, this.reportPageError, [category, message]); break; } case "LogMessage": this.handleLogMessage(message); break; case "ConsoleAPI": this.outputMessage(CATEGORY_WEBDEV, this.logConsoleAPIMessage, [message]); break; case "NetworkEvent": this.outputMessage(CATEGORY_NETWORK, this.logNetEvent, [message]); break; } }, this); }, /** * Logs a message to the Web Console that originates from the Web Console * server. * * @param object message * The message received from the server. * @return nsIDOMElement|null * The message element to display in the Web Console output. */ logConsoleAPIMessage: function (message) { let body = null; let clipboardText = null; let sourceURL = message.filename; let sourceLine = message.lineNumber; let level = message.level; let args = message.arguments; let objectActors = new Set(); let node = null; // Gather the actor IDs. args.forEach((value) => { if (WebConsoleUtils.isActorGrip(value)) { objectActors.add(value.actor); } }); switch (level) { case "log": case "info": case "warn": case "error": case "exception": case "assert": case "debug": { let msg = new Messages.ConsoleGeneric(message); node = msg.init(this.output).render().element; break; } case "table": { let msg = new Messages.ConsoleTable(message); node = msg.init(this.output).render().element; break; } case "trace": { let msg = new Messages.ConsoleTrace(message); node = msg.init(this.output).render().element; break; } case "clear": { body = l10n.getStr("consoleCleared"); clipboardText = body; break; } case "dir": { body = { arguments: args }; let clipboardArray = []; args.forEach((value) => { clipboardArray.push(VariablesView.getString(value)); }); clipboardText = clipboardArray.join(" "); break; } case "dirxml": { // We just alias console.dirxml() with console.log(). message.level = "log"; return this.logConsoleAPIMessage(message); } case "group": case "groupCollapsed": clipboardText = body = message.groupName; this.groupDepth++; break; case "groupEnd": if (this.groupDepth > 0) { this.groupDepth--; } break; case "time": { let timer = message.timer; if (!timer) { return null; } if (timer.error) { console.error(new Error(l10n.getStr(timer.error))); return null; } body = l10n.getFormatStr("timerStarted", [timer.name]); clipboardText = body; break; } case "timeEnd": { let timer = message.timer; if (!timer) { return null; } let duration = Math.round(timer.duration * 100) / 100; body = l10n.getFormatStr("timeEnd", [timer.name, duration]); clipboardText = body; break; } case "count": { let counter = message.counter; if (!counter) { return null; } if (counter.error) { console.error(l10n.getStr(counter.error)); return null; } let msg = new Messages.ConsoleGeneric(message); node = msg.init(this.output).render().element; break; } case "timeStamp": { // console.timeStamp() doesn't need to display anything. return null; } default: console.error(new Error("Unknown Console API log level: " + level)); return null; } // Release object actors for arguments coming from console API methods that // we ignore their arguments. switch (level) { case "group": case "groupCollapsed": case "groupEnd": case "time": case "timeEnd": case "count": for (let actor of objectActors) { this._releaseObject(actor); } objectActors.clear(); } if (level == "groupEnd") { // no need to continue return null; } if (!node) { node = this.createMessageNode(CATEGORY_WEBDEV, LEVELS[level], body, sourceURL, sourceLine, clipboardText, level, message.timeStamp); if (message.private) { node.setAttribute("private", true); } } if (objectActors.size > 0) { node._objectActors = objectActors; if (!node._messageObject) { let repeatNode = node.getElementsByClassName("message-repeats")[0]; repeatNode._uid += [...objectActors].join("-"); } } let workerTypeID = CONSOLE_WORKER_IDS.indexOf(message.workerType); if (workerTypeID != -1) { node.workerType = WORKERTYPES_PREFKEYS[workerTypeID]; node.setAttribute("workerType", WORKERTYPES_PREFKEYS[workerTypeID]); } return node; }, /** * Handle ConsoleAPICall objects received from the server. This method outputs * the window.console API call. * * @param object message * The console API message received from the server. */ handleConsoleAPICall: function (message) { this.outputMessage(CATEGORY_WEBDEV, this.logConsoleAPIMessage, [message]); }, /** * Reports an error in the page source, either JavaScript or CSS. * * @param nsIScriptError scriptError * The error message to report. * @return nsIDOMElement|undefined * The message element to display in the Web Console output. */ reportPageError: function (category, scriptError) { // Warnings and legacy strict errors become warnings; other types become // errors. let severity = "error"; if (scriptError.warning || scriptError.strict) { severity = "warning"; } else if (scriptError.info) { severity = "log"; } switch (category) { case CATEGORY_CSS: category = "css"; break; case CATEGORY_SECURITY: category = "security"; break; default: category = "js"; break; } let objectActors = new Set(); // Gather the actor IDs. for (let prop of ["errorMessage", "lineText"]) { let grip = scriptError[prop]; if (WebConsoleUtils.isActorGrip(grip)) { objectActors.add(grip.actor); } } let errorMessage = scriptError.errorMessage; if (errorMessage.type && errorMessage.type == "longString") { errorMessage = errorMessage.initial; } let displayOrigin = scriptError.sourceName; // TLS errors are related to the connection and not the resource; therefore // it makes sense to only display the protcol, host and port (prePath). // This also means messages are grouped for a single origin. if (scriptError.category && scriptError.category == "SHA-1 Signature") { let sourceURI = Services.io.newURI(scriptError.sourceName, null, null) .QueryInterface(Ci.nsIURL); displayOrigin = sourceURI.prePath; } // Create a new message let msg = new Messages.Simple(errorMessage, { location: { url: displayOrigin, line: scriptError.lineNumber, column: scriptError.columnNumber }, stack: scriptError.stacktrace, category: category, severity: severity, timestamp: scriptError.timeStamp, private: scriptError.private, filterDuplicates: true }); let node = msg.init(this.output).render().element; // Select the body of the message node that is displayed in the console let msgBody = node.getElementsByClassName("message-body")[0]; // Add the more info link node to messages that belong to certain categories if (scriptError.exceptionDocURL) { this.addLearnMoreWarningNode(msgBody, scriptError.exceptionDocURL); } // Collect telemetry data regarding JavaScript errors this._telemetry.logKeyed("DEVTOOLS_JAVASCRIPT_ERROR_DISPLAYED", scriptError.errorMessageName, true); if (objectActors.size > 0) { node._objectActors = objectActors; } return node; }, /** * Handle PageError objects received from the server. This method outputs the * given error. * * @param nsIScriptError pageError * The error received from the server. */ handlePageError: function (pageError) { let category = Utils.categoryForScriptError(pageError); this.outputMessage(category, this.reportPageError, [category, pageError]); }, /** * Handle log messages received from the server. This method outputs the given * message. * * @param object packet * The message packet received from the server. */ handleLogMessage: function (packet) { if (packet.message) { this.outputMessage(CATEGORY_JS, this._reportLogMessage, [packet]); } }, /** * Display log messages received from the server. * * @private * @param object packet * The message packet received from the server. * @return nsIDOMElement * The message element to render for the given log message. */ _reportLogMessage: function (packet) { let msg = packet.message; if (msg.type && msg.type == "longString") { msg = msg.initial; } let node = this.createMessageNode(CATEGORY_JS, SEVERITY_LOG, msg, null, null, null, null, packet.timeStamp); if (WebConsoleUtils.isActorGrip(packet.message)) { node._objectActors = new Set([packet.message.actor]); } return node; }, /** * Log network event. * * @param object networkInfo * The network request information to log. * @return nsIDOMElement|null * The message element to display in the Web Console output. */ logNetEvent: function (networkInfo) { let actorId = networkInfo.actor; let request = networkInfo.request; let clipboardText = request.method + " " + request.url; let severity = SEVERITY_LOG; if (networkInfo.isXHR) { clipboardText = request.method + " XHR " + request.url; severity = SEVERITY_INFO; } let mixedRequest = WebConsoleUtils.isMixedHTTPSRequest(request.url, this.contentLocation); if (mixedRequest) { severity = SEVERITY_WARNING; } let methodNode = this.document.createElementNS(XHTML_NS, "span"); methodNode.className = "method"; methodNode.textContent = request.method + " "; let messageNode = this.createMessageNode(CATEGORY_NETWORK, severity, methodNode, null, null, clipboardText, null, networkInfo.timeStamp); if (networkInfo.private) { messageNode.setAttribute("private", true); } messageNode._connectionId = actorId; messageNode.url = request.url; let body = methodNode.parentNode; body.setAttribute("aria-haspopup", true); if (networkInfo.isXHR) { let xhrNode = this.document.createElementNS(XHTML_NS, "span"); xhrNode.className = "xhr"; xhrNode.textContent = l10n.getStr("webConsoleXhrIndicator"); body.appendChild(xhrNode); body.appendChild(this.document.createTextNode(" ")); } let displayUrl = request.url; let pos = displayUrl.indexOf("?"); if (pos > -1) { displayUrl = displayUrl.substr(0, pos); } let urlNode = this.document.createElementNS(XHTML_NS, "a"); urlNode.className = "url"; urlNode.setAttribute("title", request.url); urlNode.href = request.url; urlNode.textContent = displayUrl; urlNode.draggable = false; body.appendChild(urlNode); body.appendChild(this.document.createTextNode(" ")); if (mixedRequest) { messageNode.classList.add("mixed-content"); this.makeMixedContentNode(body); } let statusNode = this.document.createElementNS(XHTML_NS, "a"); statusNode.className = "status"; body.appendChild(statusNode); let onClick = () => this.openNetworkPanel(networkInfo.actor); this._addMessageLinkCallback(urlNode, onClick); this._addMessageLinkCallback(statusNode, onClick); networkInfo.node = messageNode; this._updateNetMessage(actorId); if (this.window.NetRequest) { this.window.NetRequest.onNetworkEvent({ consoleFrame: this, response: networkInfo, node: messageNode, update: false }); } return messageNode; }, /** * Create a mixed content warning Node. * * @param linkNode * Parent to the requested urlNode. */ makeMixedContentNode: function (linkNode) { let mixedContentWarning = "[" + l10n.getStr("webConsoleMixedContentWarning") + "]"; // Mixed content warning message links to a Learn More page let mixedContentWarningNode = this.document.createElementNS(XHTML_NS, "a"); mixedContentWarningNode.title = MIXED_CONTENT_LEARN_MORE; mixedContentWarningNode.href = MIXED_CONTENT_LEARN_MORE; mixedContentWarningNode.className = "learn-more-link"; mixedContentWarningNode.textContent = mixedContentWarning; mixedContentWarningNode.draggable = false; linkNode.appendChild(mixedContentWarningNode); this._addMessageLinkCallback(mixedContentWarningNode, (event) => { event.stopPropagation(); this.owner.openLink(MIXED_CONTENT_LEARN_MORE); }); }, /* * Appends a clickable warning node to the node passed * as a parameter to the function. When a user clicks on the appended * warning node, the browser navigates to the provided url. * * @param node * The node to which we will be adding a clickable warning node. * @param url * The url which points to the page where the user can learn more * about security issues associated with the specific message that's * being logged. */ addLearnMoreWarningNode: function (node, url) { let moreInfoLabel = "[" + l10n.getStr("webConsoleMoreInfoLabel") + "]"; let warningNode = this.document.createElementNS(XHTML_NS, "a"); warningNode.title = url.split("?")[0]; warningNode.href = url; warningNode.draggable = false; warningNode.textContent = moreInfoLabel; warningNode.className = "learn-more-link"; this._addMessageLinkCallback(warningNode, (event) => { event.stopPropagation(); this.owner.openLink(url); }); node.appendChild(warningNode); }, /** * Log file activity. * * @param string fileURI * The file URI that was loaded. * @return nsIDOMElement|undefined * The message element to display in the Web Console output. */ logFileActivity: function (fileURI) { let urlNode = this.document.createElementNS(XHTML_NS, "a"); urlNode.setAttribute("title", fileURI); urlNode.className = "url"; urlNode.textContent = fileURI; urlNode.draggable = false; urlNode.href = fileURI; let outputNode = this.createMessageNode(CATEGORY_NETWORK, SEVERITY_LOG, urlNode, null, null, fileURI); this._addMessageLinkCallback(urlNode, () => { this.owner.viewSource(fileURI); }); return outputNode; }, /** * Handle the file activity messages coming from the remote Web Console. * * @param string fileURI * The file URI that was requested. */ handleFileActivity: function (fileURI) { this.outputMessage(CATEGORY_NETWORK, this.logFileActivity, [fileURI]); }, /** * Handle the reflow activity messages coming from the remote Web Console. * * @param object msg * An object holding information about a reflow batch. */ logReflowActivity: function (message) { let {start, end, sourceURL, sourceLine} = message; let duration = Math.round((end - start) * 100) / 100; let node = this.document.createElementNS(XHTML_NS, "span"); if (sourceURL) { node.textContent = l10n.getFormatStr("reflow.messageWithLink", [duration]); let a = this.document.createElementNS(XHTML_NS, "a"); a.href = "#"; a.draggable = "false"; let filename = getSourceNames(sourceURL).short; let functionName = message.functionName || l10n.getStr("stacktrace.anonymousFunction"); a.textContent = l10n.getFormatStr("reflow.messageLinkText", [functionName, filename, sourceLine]); this._addMessageLinkCallback(a, () => { this.owner.viewSourceInDebugger(sourceURL, sourceLine); }); node.appendChild(a); } else { node.textContent = l10n.getFormatStr("reflow.messageWithNoLink", [duration]); } return this.createMessageNode(CATEGORY_CSS, SEVERITY_LOG, node); }, handleReflowActivity: function (message) { this.outputMessage(CATEGORY_CSS, this.logReflowActivity, [message]); }, /** * Inform user that the window.console API has been replaced by a script * in a content page. */ logWarningAboutReplacedAPI: function () { let node = this.createMessageNode(CATEGORY_JS, SEVERITY_WARNING, l10n.getStr("ConsoleAPIDisabled")); this.outputMessage(CATEGORY_JS, node); }, /** * Handle the network events coming from the remote Web Console. * * @param object networkInfo * The network request information. */ handleNetworkEvent: function (networkInfo) { this.outputMessage(CATEGORY_NETWORK, this.logNetEvent, [networkInfo]); }, /** * Handle network event updates coming from the server. * * @param object networkInfo * The network request information. * @param object packet * Update details. */ handleNetworkEventUpdate: function (networkInfo, packet) { if (networkInfo.node && this._updateNetMessage(packet.from)) { if (this.window.NetRequest) { this.window.NetRequest.onNetworkEvent({ client: this.webConsoleClient, response: packet, node: networkInfo.node, update: true }); } this.emit("new-messages", new Set([{ update: true, node: networkInfo.node, response: packet, }])); } // For unit tests we pass the HTTP activity object to the test callback, // once requests complete. if (this.owner.lastFinishedRequestCallback && networkInfo.updates.indexOf("responseContent") > -1 && networkInfo.updates.indexOf("eventTimings") > -1) { this.owner.lastFinishedRequestCallback(networkInfo, this); } }, /** * Update an output message to reflect the latest state of a network request, * given a network event actor ID. * * @private * @param string actorId * The network event actor ID for which you want to update the message. * @return boolean * |true| if the message node was updated, or |false| otherwise. */ _updateNetMessage: function (actorId) { let networkInfo = this.webConsoleClient.getNetworkRequest(actorId); if (!networkInfo || !networkInfo.node) { return false; } let messageNode = networkInfo.node; let updates = networkInfo.updates; let hasEventTimings = updates.indexOf("eventTimings") > -1; let hasResponseStart = updates.indexOf("responseStart") > -1; let request = networkInfo.request; let methodText = (networkInfo.isXHR) ? request.method + " XHR" : request.method; let response = networkInfo.response; let updated = false; if (hasEventTimings || hasResponseStart) { let status = []; if (response.httpVersion && response.status) { status = [response.httpVersion, response.status, response.statusText]; } if (hasEventTimings) { status.push(l10n.getFormatStr("NetworkPanel.durationMS", [networkInfo.totalTime])); } let statusText = "[" + status.join(" ") + "]"; let statusNode = messageNode.getElementsByClassName("status")[0]; statusNode.textContent = statusText; messageNode.clipboardText = [methodText, request.url, statusText] .join(" "); if (hasResponseStart && response.status >= MIN_HTTP_ERROR_CODE && response.status <= MAX_HTTP_ERROR_CODE) { this.setMessageType(messageNode, CATEGORY_NETWORK, SEVERITY_ERROR); } updated = true; } if (messageNode._netPanel) { messageNode._netPanel.update(); } return updated; }, /** * Opens the network monitor and highlights the specified request. * * @param string requestId * The actor ID of the network request. */ openNetworkPanel: function (requestId) { let toolbox = gDevTools.getToolbox(this.owner.target); // The browser console doesn't have a toolbox. if (!toolbox) { return; } return toolbox.selectTool("netmonitor").then(panel => { return panel.panelWin.NetMonitorController.inspectRequest(requestId); }); }, /** * Handler for page location changes. * * @param string uri * New page location. * @param string title * New page title. */ onLocationChange: function (uri, title) { this.contentLocation = uri; if (this.owner.onLocationChange) { this.owner.onLocationChange(uri, title); } }, /** * Handler for the tabNavigated notification. * * @param string event * Event name. * @param object packet * Notification packet received from the server. */ handleTabNavigated: function (event, packet) { if (event == "will-navigate") { if (this.persistLog) { if (this.NEW_CONSOLE_OUTPUT_ENABLED) { // Add a _type to hit convertCachedPacket. packet._type = true; this.newConsoleOutput.dispatchMessageAdd(packet); } else { let marker = new Messages.NavigationMarker(packet, Date.now()); this.output.addMessage(marker); } } else { this.jsterm.clearOutput(); } } if (packet.url) { this.onLocationChange(packet.url, packet.title); } if (event == "navigate" && !packet.nativeConsoleAPI) { this.logWarningAboutReplacedAPI(); } }, /** * Output a message node. This filters a node appropriately, then sends it to * the output, regrouping and pruning output as necessary. * * Note: this call is async - the given message node may not be displayed when * you call this method. * * @param integer category * The category of the message you want to output. See the CATEGORY_* * constants. * @param function|nsIDOMElement methodOrNode * The method that creates the message element to send to the output or * the actual element. If a method is given it will be bound to the HUD * object and the arguments will be |args|. * @param array [args] * If a method is given to output the message element then the method * will be invoked with the list of arguments given here. The last * object in this array should be the packet received from the * back end. */ outputMessage: function (category, methodOrNode, args) { if (!this._outputQueue.length) { // If the queue is empty we consider that now was the last output flush. // This avoid an immediate output flush when the timer executes. this._lastOutputFlush = Date.now(); } this._outputQueue.push([category, methodOrNode, args]); this._initOutputTimer(); }, /** * Try to flush the output message queue. This takes the messages in the * output queue and displays them. Outputting stops at MESSAGES_IN_INTERVAL. * Further output is queued to happen later - see OUTPUT_INTERVAL. * * @private */ _flushMessageQueue: function () { this._outputTimerInitialized = false; if (!this._outputTimer) { return; } let startTime = Date.now(); let timeSinceFlush = startTime - this._lastOutputFlush; let shouldThrottle = this._outputQueue.length > MESSAGES_IN_INTERVAL && timeSinceFlush < THROTTLE_UPDATES; // Determine how many messages we can display now. let toDisplay = Math.min(this._outputQueue.length, MESSAGES_IN_INTERVAL); // If there aren't any messages to display (because of throttling or an // empty queue), then take care of some cleanup. Destroy items that were // pruned from the outputQueue before being displayed. if (shouldThrottle || toDisplay < 1) { while (this._itemDestroyQueue.length) { if ((Date.now() - startTime) > MAX_CLEANUP_TIME) { break; } this._destroyItem(this._itemDestroyQueue.pop()); } this._initOutputTimer(); return; } // Try to prune the message queue. let shouldPrune = false; if (this._outputQueue.length > toDisplay && this._pruneOutputQueue()) { toDisplay = Math.min(this._outputQueue.length, toDisplay); shouldPrune = true; } let batch = this._outputQueue.splice(0, toDisplay); let outputNode = this.outputNode; let lastVisibleNode = null; let scrollNode = this.outputWrapper; let hudIdSupportsString = WebConsoleUtils.supportsString(this.hudId); // We won't bother to try to restore scroll position if this is showing // a lot of messages at once (and there are still items in the queue). // It is going to purge whatever you were looking at anyway. let scrolledToBottom = shouldPrune || Utils.isOutputScrolledToBottom(outputNode, scrollNode); // Output the current batch of messages. let messages = new Set(); for (let i = 0; i < batch.length; i++) { let item = batch[i]; let result = this._outputMessageFromQueue(hudIdSupportsString, item); if (result) { messages.add({ node: result.isRepeated ? result.isRepeated : result.node, response: result.message, update: !!result.isRepeated, }); if (result.visible && result.node == this.outputNode.lastChild) { lastVisibleNode = result.node; } } } let oldScrollHeight = 0; let removedNodes = 0; // Prune messages from the DOM, but only if needed. if (shouldPrune || !this._outputQueue.length) { // Only bother measuring the scrollHeight if not scrolled to bottom, // since the oldScrollHeight will not be used if it is. if (!scrolledToBottom) { oldScrollHeight = scrollNode.scrollHeight; } let categories = Object.keys(this._pruneCategoriesQueue); categories.forEach(function _pruneOutput(category) { removedNodes += this.pruneOutputIfNecessary(category); }, this); this._pruneCategoriesQueue = {}; } let isInputOutput = lastVisibleNode && (lastVisibleNode.category == CATEGORY_INPUT || lastVisibleNode.category == CATEGORY_OUTPUT); // Scroll to the new node if it is not filtered, and if the output node is // scrolled at the bottom or if the new node is a jsterm input/output // message. if (lastVisibleNode && (scrolledToBottom || isInputOutput)) { Utils.scrollToVisible(lastVisibleNode); } else if (!scrolledToBottom && removedNodes > 0 && oldScrollHeight != scrollNode.scrollHeight) { // If there were pruned messages and if scroll is not at the bottom, then // we need to adjust the scroll location. scrollNode.scrollTop -= oldScrollHeight - scrollNode.scrollHeight; } if (messages.size) { this.emit("new-messages", messages); } // If the output queue is empty, then run _flushCallback. if (this._outputQueue.length === 0 && this._flushCallback) { if (this._flushCallback() === false) { this._flushCallback = null; } } this._initOutputTimer(); // Resize the output area in case a vertical scrollbar has been added this.resize(); this._lastOutputFlush = Date.now(); }, /** * Initialize the output timer. * @private */ _initOutputTimer: function () { let panelIsDestroyed = !this._outputTimer; let alreadyScheduled = this._outputTimerInitialized; let nothingToDo = !this._itemDestroyQueue.length && !this._outputQueue.length; // Don't schedule a callback in the following cases: if (panelIsDestroyed || alreadyScheduled || nothingToDo) { return; } this._outputTimerInitialized = true; this._outputTimer.initWithCallback(this._flushMessageQueue, OUTPUT_INTERVAL, Ci.nsITimer.TYPE_ONE_SHOT); }, /** * Output a message from the queue. * * @private * @param nsISupportsString hudIdSupportsString * The HUD ID as an nsISupportsString. * @param array item * An item from the output queue - this item represents a message. * @return object * An object that holds the following properties: * - node: the DOM element of the message. * - isRepeated: the DOM element of the original message, if this is * a repeated message, otherwise null. * - visible: boolean that tells if the message is visible. */ _outputMessageFromQueue: function (hudIdSupportsString, item) { let [, methodOrNode, args] = item; // The last object in the args array should be message // object or response packet received from the server. let message = (args && args.length) ? args[args.length - 1] : null; let node = typeof methodOrNode == "function" ? methodOrNode.apply(this, args || []) : methodOrNode; if (!node) { return null; } let isFiltered = this.filterMessageNode(node); let isRepeated = this._filterRepeatedMessage(node); // If a clear message is processed while the webconsole is opened, the UI // should be cleared. // Do not clear the output if the current frame is owned by a Browser Console. if (message && message.level == "clear" && !this.isBrowserConsole) { // Do not clear the consoleStorage here as it has been cleared already // by the clear method, only clear the UI. this.jsterm.clearOutput(false); } let visible = !isRepeated && !isFiltered; if (!isRepeated) { this.outputNode.appendChild(node); this._pruneCategoriesQueue[node.category] = true; let nodeID = node.getAttribute("id"); Services.obs.notifyObservers(hudIdSupportsString, "web-console-message-created", nodeID); } if (node._onOutput) { node._onOutput(); delete node._onOutput; } return { visible: visible, node: node, isRepeated: isRepeated, message: message }; }, /** * Prune the queue of messages to display. This avoids displaying messages * that will be removed at the end of the queue anyway. * @private */ _pruneOutputQueue: function () { let nodes = {}; // Group the messages per category. this._outputQueue.forEach(function (item, index) { let [category] = item; if (!(category in nodes)) { nodes[category] = []; } nodes[category].push(index); }, this); let pruned = 0; // Loop through the categories we found and prune if needed. for (let category in nodes) { let limit = Utils.logLimitForCategory(category); let indexes = nodes[category]; if (indexes.length > limit) { let n = Math.max(0, indexes.length - limit); pruned += n; for (let i = n - 1; i >= 0; i--) { this._itemDestroyQueue.push(this._outputQueue[indexes[i]]); this._outputQueue.splice(indexes[i], 1); } } } return pruned; }, /** * Destroy an item that was once in the outputQueue but isn't needed * after all. * * @private * @param array item * The item you want to destroy. Does not remove it from the output * queue. */ _destroyItem: function (item) { // TODO: handle object releasing in a more elegant way once all console // messages use the new API - bug 778766. let [category, methodOrNode, args] = item; if (typeof methodOrNode != "function" && methodOrNode._objectActors) { for (let actor of methodOrNode._objectActors) { this._releaseObject(actor); } methodOrNode._objectActors.clear(); } if (methodOrNode == this.output._flushMessageQueue && args[0]._objectActors) { for (let arg of args) { if (!arg._objectActors) { continue; } for (let actor of arg._objectActors) { this._releaseObject(actor); } arg._objectActors.clear(); } } if (category == CATEGORY_NETWORK) { let connectionId = null; if (methodOrNode == this.logNetEvent) { connectionId = args[0].actor; } else if (typeof methodOrNode != "function") { connectionId = methodOrNode._connectionId; } if (connectionId && this.webConsoleClient.hasNetworkRequest(connectionId)) { this.webConsoleClient.removeNetworkRequest(connectionId); this._releaseObject(connectionId); } } else if (category == CATEGORY_WEBDEV && methodOrNode == this.logConsoleAPIMessage) { args[0].arguments.forEach((value) => { if (WebConsoleUtils.isActorGrip(value)) { this._releaseObject(value.actor); } }); } else if (category == CATEGORY_JS && methodOrNode == this.reportPageError) { let pageError = args[1]; for (let prop of ["errorMessage", "lineText"]) { let grip = pageError[prop]; if (WebConsoleUtils.isActorGrip(grip)) { this._releaseObject(grip.actor); } } } else if (category == CATEGORY_JS && methodOrNode == this._reportLogMessage) { if (WebConsoleUtils.isActorGrip(args[0].message)) { this._releaseObject(args[0].message.actor); } } }, /** * Cleans up a message via a node that may or may not * have actually been rendered in the DOM. Currently, only * cleans up React components. * * @param nsIDOMNode node * The message node you want to clean up. */ unmountMessage(node) { // Unmount the Frame component with the message location let locationNode = node.querySelector(".message-location"); if (locationNode) { this.ReactDOM.unmountComponentAtNode(locationNode); } // Unmount the StackTrace component if present in the message let stacktraceNode = node.querySelector(".stacktrace"); if (stacktraceNode) { this.ReactDOM.unmountComponentAtNode(stacktraceNode); } }, /** * Ensures that the number of message nodes of type category don't exceed that * category's line limit by removing old messages as needed. * * @param integer category * The category of message nodes to prune if needed. * @return number * The number of removed nodes. */ pruneOutputIfNecessary: function (category) { let logLimit = Utils.logLimitForCategory(category); let messageNodes = this.outputNode.querySelectorAll(".message[category=" + CATEGORY_CLASS_FRAGMENTS[category] + "]"); let n = Math.max(0, messageNodes.length - logLimit); [...messageNodes].slice(0, n).forEach(this.removeOutputMessage, this); return n; }, /** * Remove a given message from the output. * * @param nsIDOMNode node * The message node you want to remove. */ removeOutputMessage: function (node) { if (node._messageObject) { node._messageObject.destroy(); } if (node._objectActors) { for (let actor of node._objectActors) { this._releaseObject(actor); } node._objectActors.clear(); } if (node.category == CATEGORY_CSS || node.category == CATEGORY_SECURITY) { let repeatNode = node.getElementsByClassName("message-repeats")[0]; if (repeatNode && repeatNode._uid) { delete this._repeatNodes[repeatNode._uid]; } } else if (node._connectionId && node.category == CATEGORY_NETWORK) { this.webConsoleClient.removeNetworkRequest(node._connectionId); this._releaseObject(node._connectionId); } else if (node.classList.contains("inlined-variables-view")) { let view = node._variablesView; if (view) { view.controller.releaseActors(); } node._variablesView = null; } this.unmountMessage(node); node.remove(); }, /** * Given a category and message body, creates a DOM node to represent an * incoming message. The timestamp is automatically added. * * @param number category * The category of the message: one of the CATEGORY_* constants. * @param number severity * The severity of the message: one of the SEVERITY_* constants; * @param string|nsIDOMNode body * The body of the message, either a simple string or a DOM node. * @param string sourceURL [optional] * The URL of the source file that emitted the error. * @param number sourceLine [optional] * The line number on which the error occurred. If zero or omitted, * there is no line number associated with this message. * @param string clipboardText [optional] * The text that should be copied to the clipboard when this node is * copied. If omitted, defaults to the body text. If `body` is not * a string, then the clipboard text must be supplied. * @param number level [optional] * The level of the console API message. * @param number timestamp [optional] * The timestamp to use for this message node. If omitted, the current * date and time is used. * @return nsIDOMNode * The message node: a DIV ready to be inserted into the Web Console * output node. */ createMessageNode: function (category, severity, body, sourceURL, sourceLine, clipboardText, level, timestamp) { if (typeof body != "string" && clipboardText == null && body.innerText) { clipboardText = body.innerText; } let indentNode = this.document.createElementNS(XHTML_NS, "span"); indentNode.className = "indent"; // Apply the current group by indenting appropriately. let indent = this.groupDepth * GROUP_INDENT; indentNode.style.width = indent + "px"; // Make the icon container, which is a vertical box. Its purpose is to // ensure that the icon stays anchored at the top of the message even for // long multi-line messages. let iconContainer = this.document.createElementNS(XHTML_NS, "span"); iconContainer.className = "icon"; // Create the message body, which contains the actual text of the message. let bodyNode = this.document.createElementNS(XHTML_NS, "span"); bodyNode.className = "message-body-wrapper message-body devtools-monospace"; // Store the body text, since it is needed later for the variables view. let storedBody = body; // If a string was supplied for the body, turn it into a DOM node and an // associated clipboard string now. clipboardText = clipboardText || (body + (sourceURL ? " @ " + sourceURL : "") + (sourceLine ? ":" + sourceLine : "")); timestamp = timestamp || Date.now(); // Create the containing node and append all its elements to it. let node = this.document.createElementNS(XHTML_NS, "div"); node.id = "console-msg-" + gSequenceId(); node.className = "message"; node.clipboardText = clipboardText; node.timestamp = timestamp; this.setMessageType(node, category, severity); if (body instanceof Ci.nsIDOMNode) { bodyNode.appendChild(body); } else { let str = undefined; if (level == "dir") { str = VariablesView.getString(body.arguments[0]); } else { str = body; } if (str !== undefined) { body = this.document.createTextNode(str); bodyNode.appendChild(body); } } // Add the message repeats node only when needed. let repeatNode = null; if (category != CATEGORY_INPUT && category != CATEGORY_OUTPUT && category != CATEGORY_NETWORK && !(category == CATEGORY_CSS && severity == SEVERITY_LOG)) { repeatNode = this.document.createElementNS(XHTML_NS, "span"); repeatNode.setAttribute("value", "1"); repeatNode.className = "message-repeats"; repeatNode.textContent = 1; repeatNode._uid = [bodyNode.textContent, category, severity, level, sourceURL, sourceLine].join(":"); } // Create the timestamp. let timestampNode = this.document.createElementNS(XHTML_NS, "span"); timestampNode.className = "timestamp devtools-monospace"; let timestampString = l10n.timestampString(timestamp); timestampNode.textContent = timestampString + " "; // Create the source location (e.g. www.example.com:6) that sits on the // right side of the message, if applicable. let locationNode; if (sourceURL && IGNORED_SOURCE_URLS.indexOf(sourceURL) == -1) { locationNode = this.createLocationNode({url: sourceURL, line: sourceLine}); } node.appendChild(timestampNode); node.appendChild(indentNode); node.appendChild(iconContainer); // Display the variables view after the message node. if (level == "dir") { let options = { objectActor: storedBody.arguments[0], targetElement: bodyNode, hideFilterInput: true, }; this.jsterm.openVariablesView(options).then((view) => { node._variablesView = view; if (node.classList.contains("hidden-message")) { node.classList.remove("hidden-message"); } }); node.classList.add("inlined-variables-view"); } node.appendChild(bodyNode); if (repeatNode) { node.appendChild(repeatNode); } if (locationNode) { node.appendChild(locationNode); } node.appendChild(this.document.createTextNode("\n")); return node; }, /** * Creates the anchor that displays the textual location of an incoming * message. * * @param {Object} location * An object containing url, line and column number of the message source. * @return {Element} * The new anchor element, ready to be added to the message node. */ createLocationNode: function (location) { let locationNode = this.document.createElementNS(XHTML_NS, "div"); locationNode.className = "message-location devtools-monospace"; // Make the location clickable. let onClick = ({ url, line }) => { let category = locationNode.closest(".message").category; let target = null; if (/^Scratchpad\/\d+$/.test(url)) { target = "scratchpad"; } else if (category === CATEGORY_CSS) { target = "styleeditor"; } else if (category === CATEGORY_JS || category === CATEGORY_WEBDEV) { target = "jsdebugger"; } else if (/\.js$/.test(url)) { // If it ends in .js, let's attempt to open in debugger // anyway, as this falls back to normal view-source. target = "jsdebugger"; } else { // Point everything else to debugger, if source not available, // it will fall back to view-source. target = "jsdebugger"; } switch (target) { case "scratchpad": this.owner.viewSourceInScratchpad(url, line); return; case "jsdebugger": this.owner.viewSourceInDebugger(url, line); return; case "styleeditor": this.owner.viewSourceInStyleEditor(url, line); return; } // No matching tool found; use old school view-source this.owner.viewSource(url, line); }; const toolbox = gDevTools.getToolbox(this.owner.target); let { url, line, column } = location; let source = url ? url.split(" -> ").pop() : ""; this.ReactDOM.render(this.FrameView({ frame: { source, line, column }, showEmptyPathAsHost: true, onClick, sourceMapService: toolbox ? toolbox._sourceMapService : null, }), locationNode); return locationNode; }, /** * Adjusts the category and severity of the given message. * * @param nsIDOMNode messageNode * The message node to alter. * @param number category * The category for the message; one of the CATEGORY_ constants. * @param number severity * The severity for the message; one of the SEVERITY_ constants. * @return void */ setMessageType: function (messageNode, category, severity) { messageNode.category = category; messageNode.severity = severity; messageNode.setAttribute("category", CATEGORY_CLASS_FRAGMENTS[category]); messageNode.setAttribute("severity", SEVERITY_CLASS_FRAGMENTS[severity]); messageNode.setAttribute("filter", MESSAGE_PREFERENCE_KEYS[category][severity]); }, /** * Add the mouse event handlers needed to make a link. * * @private * @param nsIDOMNode node * The node for which you want to add the event handlers. * @param function callback * The function you want to invoke on click. */ _addMessageLinkCallback: function (node, callback) { node.addEventListener("mousedown", (event) => { this._mousedown = true; this._startX = event.clientX; this._startY = event.clientY; }, false); node.addEventListener("click", (event) => { let mousedown = this._mousedown; this._mousedown = false; event.preventDefault(); // Do not allow middle/right-click or 2+ clicks. if (event.detail != 1 || event.button != 0) { return; } // If this event started with a mousedown event and it ends at a different // location, we consider this text selection. if (mousedown && (this._startX != event.clientX) && (this._startY != event.clientY)) { this._startX = this._startY = undefined; return; } this._startX = this._startY = undefined; callback.call(this, event); }, false); }, /** * Handler for the pref-changed event coming from the toolbox. * Currently this function only handles the timestamps preferences. * * @private * @param object event * This parameter is a string that holds the event name * pref-changed in this case. * @param object data * This is the pref-changed data object. */ _onToolboxPrefChanged: function (event, data) { if (data.pref == PREF_MESSAGE_TIMESTAMP) { if (data.newValue) { this.outputNode.classList.remove("hideTimestamps"); } else { this.outputNode.classList.add("hideTimestamps"); } } }, /** * Copies the selected items to the system clipboard. * * @param object options * - linkOnly: * An optional flag to copy only URL without other meta-information. * Default is false. * - contextmenu: * An optional flag to copy the last clicked item which brought * up the context menu if nothing is selected. Default is false. */ copySelectedItems: function (options) { options = options || { linkOnly: false, contextmenu: false }; // Gather up the selected items and concatenate their clipboard text. let strings = []; let children = this.output.getSelectedMessages(); if (!children.length && options.contextmenu) { children = [this._contextMenuHandler.lastClickedMessage]; } for (let item of children) { // Ensure the selected item hasn't been filtered by type or string. if (!item.classList.contains("filtered-by-type") && !item.classList.contains("filtered-by-string")) { if (options.linkOnly) { strings.push(item.url); } else { strings.push(item.clipboardText); } } } clipboardHelper.copyString(strings.join("\n")); }, /** * Object properties provider. This function gives you the properties of the * remote object you want. * * @param string actor * The object actor ID from which you want the properties. * @param function callback * Function you want invoked once the properties are received. */ objectPropertiesProvider: function (actor, callback) { this.webConsoleClient.inspectObjectProperties(actor, function (response) { if (response.error) { console.error("Failed to retrieve the object properties from the " + "server. Error: " + response.error); return; } callback(response.properties); }); }, /** * Release an actor. * * @private * @param string actor * The actor ID you want to release. */ _releaseObject: function (actor) { if (this.proxy) { this.proxy.releaseActor(actor); } }, /** * Open the selected item's URL in a new tab. */ openSelectedItemInTab: function () { let item = this.output.getSelectedMessages(1)[0] || this._contextMenuHandler.lastClickedMessage; if (!item || !item.url) { return; } this.owner.openLink(item.url); }, /** * Destroy the WebConsoleFrame object. Call this method to avoid memory leaks * when the Web Console is closed. * * @return object * A promise that is resolved when the WebConsoleFrame instance is * destroyed. */ destroy: function () { if (this._destroyer) { return this._destroyer.promise; } this._destroyer = promise.defer(); let toolbox = gDevTools.getToolbox(this.owner.target); if (toolbox) { toolbox.off("webconsole-selected", this._onPanelSelected); } gDevTools.off("pref-changed", this._onToolboxPrefChanged); this.window.removeEventListener("resize", this.resize, true); this._repeatNodes = {}; this._outputQueue.forEach(this._destroyItem, this); this._outputQueue = []; this._itemDestroyQueue.forEach(this._destroyItem, this); this._itemDestroyQueue = []; this._pruneCategoriesQueue = {}; this.webConsoleClient.clearNetworkRequests(); // Unmount any currently living frame components in DOM, since // currently we only clean up messages in `this.removeOutputMessage`, // via `this.pruneOutputIfNecessary`. let liveMessages = this.outputNode.querySelectorAll(".message"); Array.prototype.forEach.call(liveMessages, this.unmountMessage); if (this._outputTimerInitialized) { this._outputTimerInitialized = false; this._outputTimer.cancel(); } this._outputTimer = null; if (this.jsterm) { this.jsterm.off("sidebar-opened", this.resize); this.jsterm.off("sidebar-closed", this.resize); this.jsterm.destroy(); this.jsterm = null; } this.output.destroy(); this.output = null; this.React = this.ReactDOM = this.FrameView = null; if (this._contextMenuHandler) { this._contextMenuHandler.destroy(); this._contextMenuHandler = null; } this._commandController = null; let onDestroy = () => { this._destroyer.resolve(null); }; if (this.proxy) { this.proxy.disconnect().then(onDestroy); this.proxy = null; } else { onDestroy(); } return this._destroyer.promise; }, }; /** * Utils: a collection of globally used functions. */ var Utils = { /** * Scrolls a node so that it's visible in its containing element. * * @param nsIDOMNode node * The node to make visible. * @returns void */ scrollToVisible: function (node) { node.scrollIntoView(false); }, /** * Check if the given output node is scrolled to the bottom. * * @param nsIDOMNode outputNode * @param nsIDOMNode scrollNode * @return boolean * True if the output node is scrolled to the bottom, or false * otherwise. */ isOutputScrolledToBottom: function (outputNode, scrollNode) { let lastNodeHeight = outputNode.lastChild ? outputNode.lastChild.clientHeight : 0; return scrollNode.scrollTop + scrollNode.clientHeight >= scrollNode.scrollHeight - lastNodeHeight / 2; }, /** * Determine the category of a given nsIScriptError. * * @param nsIScriptError scriptError * The script error you want to determine the category for. * @return CATEGORY_JS|CATEGORY_CSS|CATEGORY_SECURITY * Depending on the script error CATEGORY_JS, CATEGORY_CSS, or * CATEGORY_SECURITY can be returned. */ categoryForScriptError: function (scriptError) { let category = scriptError.category; if (/^(?:CSS|Layout)\b/.test(category)) { return CATEGORY_CSS; } switch (category) { case "Mixed Content Blocker": case "Mixed Content Message": case "CSP": case "Invalid HSTS Headers": case "Invalid HPKP Headers": case "SHA-1 Signature": case "Insecure Password Field": case "SSL": case "CORS": case "Iframe Sandbox": case "Tracking Protection": case "Sub-resource Integrity": return CATEGORY_SECURITY; default: return CATEGORY_JS; } }, /** * Retrieve the limit of messages for a specific category. * * @param number category * The category of messages you want to retrieve the limit for. See the * CATEGORY_* constants. * @return number * The number of messages allowed for the specific category. */ logLimitForCategory: function (category) { let logLimit = DEFAULT_LOG_LIMIT; try { let prefName = CATEGORY_CLASS_FRAGMENTS[category]; logLimit = Services.prefs.getIntPref("devtools.hud.loglimit." + prefName); logLimit = Math.max(logLimit, 1); } catch (e) { // Ignore any exceptions } return logLimit; }, }; // CommandController /** * A controller (an instance of nsIController) that makes editing actions * behave appropriately in the context of the Web Console. */ function CommandController(webConsole) { this.owner = webConsole; } CommandController.prototype = { /** * Selects all the text in the HUD output. */ selectAll: function () { this.owner.output.selectAllMessages(); }, /** * Open the URL of the selected message in a new tab. */ openURL: function () { this.owner.openSelectedItemInTab(); }, copyURL: function () { this.owner.copySelectedItems({ linkOnly: true, contextmenu: true }); }, /** * Copies the last clicked message. */ copyLastClicked: function () { this.owner.copySelectedItems({ linkOnly: false, contextmenu: true }); }, supportsCommand: function (command) { if (!this.owner || !this.owner.output) { return false; } return this.isCommandEnabled(command); }, isCommandEnabled: function (command) { switch (command) { case "consoleCmd_openURL": case "consoleCmd_copyURL": { // Only enable URL-related actions if node is Net Activity. let selectedItem = this.owner.output.getSelectedMessages(1)[0] || this.owner._contextMenuHandler.lastClickedMessage; return selectedItem && "url" in selectedItem; } case "cmd_copy": { // Only copy if we right-clicked the console and there's no selected // text. With text selected, we want to fall back onto the default // copy behavior. return this.owner._contextMenuHandler.lastClickedMessage && !this.owner.output.getSelectedMessages(1)[0]; } case "cmd_selectAll": return true; } return false; }, doCommand: function (command) { switch (command) { case "consoleCmd_openURL": this.openURL(); break; case "consoleCmd_copyURL": this.copyURL(); break; case "cmd_copy": this.copyLastClicked(); break; case "cmd_selectAll": this.selectAll(); break; } } }; // Web Console connection proxy /** * The WebConsoleConnectionProxy handles the connection between the Web Console * and the application we connect to through the remote debug protocol. * * @constructor * @param object webConsoleFrame * The WebConsoleFrame object that owns this connection proxy. * @param RemoteTarget target * The target that the console will connect to. */ function WebConsoleConnectionProxy(webConsoleFrame, target) { this.webConsoleFrame = webConsoleFrame; this.target = target; this._onPageError = this._onPageError.bind(this); this._onLogMessage = this._onLogMessage.bind(this); this._onConsoleAPICall = this._onConsoleAPICall.bind(this); this._onNetworkEvent = this._onNetworkEvent.bind(this); this._onNetworkEventUpdate = this._onNetworkEventUpdate.bind(this); this._onFileActivity = this._onFileActivity.bind(this); this._onReflowActivity = this._onReflowActivity.bind(this); this._onServerLogCall = this._onServerLogCall.bind(this); this._onTabNavigated = this._onTabNavigated.bind(this); this._onAttachConsole = this._onAttachConsole.bind(this); this._onCachedMessages = this._onCachedMessages.bind(this); this._connectionTimeout = this._connectionTimeout.bind(this); this._onLastPrivateContextExited = this._onLastPrivateContextExited.bind(this); } WebConsoleConnectionProxy.prototype = { /** * The owning Web Console Frame instance. * * @see WebConsoleFrame * @type object */ webConsoleFrame: null, /** * The target that the console connects to. * @type RemoteTarget */ target: null, /** * The DebuggerClient object. * * @see DebuggerClient * @type object */ client: null, /** * The WebConsoleClient object. * * @see WebConsoleClient * @type object */ webConsoleClient: null, /** * Tells if the connection is established. * @type boolean */ connected: false, /** * Timer used for the connection. * @private * @type object */ _connectTimer: null, _connectDefer: null, _disconnecter: null, /** * The WebConsoleActor ID. * * @private * @type string */ _consoleActor: null, /** * Tells if the window.console object of the remote web page is the native * object or not. * @private * @type boolean */ _hasNativeConsoleAPI: false, /** * Initialize a debugger client and connect it to the debugger server. * * @return object * A promise object that is resolved/rejected based on the success of * the connection initialization. */ connect: function () { if (this._connectDefer) { return this._connectDefer.promise; } this._connectDefer = promise.defer(); let timeout = Services.prefs.getIntPref(PREF_CONNECTION_TIMEOUT); this._connectTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); this._connectTimer.initWithCallback(this._connectionTimeout, timeout, Ci.nsITimer.TYPE_ONE_SHOT); let connPromise = this._connectDefer.promise; connPromise.then(() => { this._connectTimer.cancel(); this._connectTimer = null; }, () => { this._connectTimer = null; }); let client = this.client = this.target.client; if (this.target.isWorkerTarget) { // XXXworkers: Not Console API yet inside of workers (Bug 1209353). } else { client.addListener("logMessage", this._onLogMessage); client.addListener("pageError", this._onPageError); client.addListener("consoleAPICall", this._onConsoleAPICall); client.addListener("fileActivity", this._onFileActivity); client.addListener("reflowActivity", this._onReflowActivity); client.addListener("serverLogCall", this._onServerLogCall); client.addListener("lastPrivateContextExited", this._onLastPrivateContextExited); } this.target.on("will-navigate", this._onTabNavigated); this.target.on("navigate", this._onTabNavigated); this._consoleActor = this.target.form.consoleActor; if (this.target.isTabActor) { let tab = this.target.form; this.webConsoleFrame.onLocationChange(tab.url, tab.title); } this._attachConsole(); return connPromise; }, /** * Connection timeout handler. * @private */ _connectionTimeout: function () { let error = { error: "timeout", message: l10n.getStr("connectionTimeout"), }; this._connectDefer.reject(error); }, /** * Attach to the Web Console actor. * @private */ _attachConsole: function () { let listeners = ["PageError", "ConsoleAPI", "NetworkActivity", "FileActivity"]; this.client.attachConsole(this._consoleActor, listeners, this._onAttachConsole); }, /** * The "attachConsole" response handler. * * @private * @param object response * The JSON response object received from the server. * @param object webConsoleClient * The WebConsoleClient instance for the attached console, for the * specific tab we work with. */ _onAttachConsole: function (response, webConsoleClient) { if (response.error) { console.error("attachConsole failed: " + response.error + " " + response.message); this._connectDefer.reject(response); return; } this.webConsoleClient = webConsoleClient; this._hasNativeConsoleAPI = response.nativeConsoleAPI; let saveBodiesPref = this.webConsoleFrame._filterPrefsPrefix + "saveBodies"; let saveBodies = Services.prefs.getBoolPref(saveBodiesPref); this.webConsoleFrame.setSaveRequestAndResponseBodies(saveBodies); this.webConsoleClient.on("networkEvent", this._onNetworkEvent); this.webConsoleClient.on("networkEventUpdate", this._onNetworkEventUpdate); let msgs = ["PageError", "ConsoleAPI"]; this.webConsoleClient.getCachedMessages(msgs, this._onCachedMessages); this.webConsoleFrame._onUpdateListeners(); }, /** * Dispatch a message add on the new frontend and emit an event for tests. */ dispatchMessageAdd: function(packet) { this.webConsoleFrame.newConsoleOutput.dispatchMessageAdd(packet); }, /** * Batched dispatch of messages. */ dispatchMessagesAdd: function(packets) { this.webConsoleFrame.newConsoleOutput.dispatchMessagesAdd(packets); }, /** * The "cachedMessages" response handler. * * @private * @param object response * The JSON response object received from the server. */ _onCachedMessages: function (response) { if (response.error) { console.error("Web Console getCachedMessages error: " + response.error + " " + response.message); this._connectDefer.reject(response); return; } if (!this._connectTimer) { // This happens if the promise is rejected (eg. a timeout), but the // connection attempt is successful, nonetheless. console.error("Web Console getCachedMessages error: invalid state."); } let messages = response.messages.concat(...this.webConsoleClient.getNetworkEvents()); messages.sort((a, b) => a.timeStamp - b.timeStamp); if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { // Filter out CSS page errors. messages = messages.filter(message => !(message._type == "PageError" && Utils.categoryForScriptError(message) === CATEGORY_CSS)); this.dispatchMessagesAdd(messages); } else { this.webConsoleFrame.displayCachedMessages(messages); if (!this._hasNativeConsoleAPI) { this.webConsoleFrame.logWarningAboutReplacedAPI(); } } this.connected = true; this._connectDefer.resolve(this); }, /** * The "pageError" message type handler. We redirect any page errors to the UI * for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onPageError: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { let category = Utils.categoryForScriptError(packet.pageError); if (category !== CATEGORY_CSS) { this.dispatchMessageAdd(packet); } return; } this.webConsoleFrame.handlePageError(packet.pageError); } }, /** * The "logMessage" message type handler. We redirect any message to the UI * for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onLogMessage: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { this.webConsoleFrame.handleLogMessage(packet); } }, /** * The "consoleAPICall" message type handler. We redirect any message to * the UI for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onConsoleAPICall: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { this.dispatchMessageAdd(packet); } else { this.webConsoleFrame.handleConsoleAPICall(packet.message); } } }, /** * The "networkEvent" message type handler. We redirect any message to * the UI for displaying. * * @private * @param string type * Message type. * @param object networkInfo * The network request information. */ _onNetworkEvent: function (type, networkInfo) { if (this.webConsoleFrame) { if (this.webConsoleFrame.NEW_CONSOLE_OUTPUT_ENABLED) { this.dispatchMessageAdd(networkInfo); } else { this.webConsoleFrame.handleNetworkEvent(networkInfo); } } }, /** * The "networkEventUpdate" message type handler. We redirect any message to * the UI for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. * @param object networkInfo * The network request information. */ _onNetworkEventUpdate: function (type, { packet, networkInfo }) { if (this.webConsoleFrame) { this.webConsoleFrame.handleNetworkEventUpdate(networkInfo, packet); } }, /** * The "fileActivity" message type handler. We redirect any message to * the UI for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onFileActivity: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { this.webConsoleFrame.handleFileActivity(packet.uri); } }, _onReflowActivity: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { this.webConsoleFrame.handleReflowActivity(packet); } }, /** * The "serverLogCall" message type handler. We redirect any message to * the UI for displaying. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onServerLogCall: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { this.webConsoleFrame.handleConsoleAPICall(packet.message); } }, /** * The "lastPrivateContextExited" message type handler. When this message is * received the Web Console UI is cleared. * * @private * @param string type * Message type. * @param object packet * The message received from the server. */ _onLastPrivateContextExited: function (type, packet) { if (this.webConsoleFrame && packet.from == this._consoleActor) { this.webConsoleFrame.jsterm.clearPrivateMessages(); } }, /** * The "will-navigate" and "navigate" event handlers. We redirect any message * to the UI for displaying. * * @private * @param string event * Event type. * @param object packet * The message received from the server. */ _onTabNavigated: function (event, packet) { if (!this.webConsoleFrame) { return; } this.webConsoleFrame.handleTabNavigated(event, packet); }, /** * Release an object actor. * * @param string actor * The actor ID to send the request to. */ releaseActor: function (actor) { if (this.client) { this.client.release(actor); } }, /** * Disconnect the Web Console from the remote server. * * @return object * A promise object that is resolved when disconnect completes. */ disconnect: function () { if (this._disconnecter) { return this._disconnecter.promise; } this._disconnecter = promise.defer(); if (!this.client) { this._disconnecter.resolve(null); return this._disconnecter.promise; } this.client.removeListener("logMessage", this._onLogMessage); this.client.removeListener("pageError", this._onPageError); this.client.removeListener("consoleAPICall", this._onConsoleAPICall); this.client.removeListener("fileActivity", this._onFileActivity); this.client.removeListener("reflowActivity", this._onReflowActivity); this.client.removeListener("serverLogCall", this._onServerLogCall); this.client.removeListener("lastPrivateContextExited", this._onLastPrivateContextExited); this.webConsoleClient.off("networkEvent", this._onNetworkEvent); this.webConsoleClient.off("networkEventUpdate", this._onNetworkEventUpdate); this.target.off("will-navigate", this._onTabNavigated); this.target.off("navigate", this._onTabNavigated); this.client = null; this.webConsoleClient = null; this.target = null; this.connected = false; this.webConsoleFrame = null; this._disconnecter.resolve(null); return this._disconnecter.promise; }, }; // Context Menu /* * ConsoleContextMenu this used to handle the visibility of context menu items. * * @constructor * @param object owner * The WebConsoleFrame instance that owns this object. */ function ConsoleContextMenu(owner) { this.owner = owner; this.popup = this.owner.document.getElementById("output-contextmenu"); this.build = this.build.bind(this); this.popup.addEventListener("popupshowing", this.build); } ConsoleContextMenu.prototype = { lastClickedMessage: null, /* * Handle to show/hide context menu item. */ build: function (event) { let metadata = this.getSelectionMetadata(event.rangeParent); for (let element of this.popup.children) { element.hidden = this.shouldHideMenuItem(element, metadata); } }, /* * Get selection information from the view. * * @param nsIDOMElement clickElement * The DOM element the user clicked on. * @return object * Selection metadata. */ getSelectionMetadata: function (clickElement) { let metadata = { selectionType: "", selection: new Set(), }; let selectedItems = this.owner.output.getSelectedMessages(); if (!selectedItems.length) { let clickedItem = this.owner.output.getMessageForElement(clickElement); if (clickedItem) { this.lastClickedMessage = clickedItem; selectedItems = [clickedItem]; } } metadata.selectionType = selectedItems.length > 1 ? "multiple" : "single"; let selection = metadata.selection; for (let item of selectedItems) { switch (item.category) { case CATEGORY_NETWORK: selection.add("network"); break; case CATEGORY_CSS: selection.add("css"); break; case CATEGORY_JS: selection.add("js"); break; case CATEGORY_WEBDEV: selection.add("webdev"); break; case CATEGORY_SERVER: selection.add("server"); break; } } return metadata; }, /* * Determine if an item should be hidden. * * @param nsIDOMElement menuItem * @param object metadata * @return boolean * Whether the given item should be hidden or not. */ shouldHideMenuItem: function (menuItem, metadata) { let selectionType = menuItem.getAttribute("selectiontype"); if (selectionType && !metadata.selectionType == selectionType) { return true; } let selection = menuItem.getAttribute("selection"); if (!selection) { return false; } let shouldHide = true; let itemData = selection.split("|"); for (let type of metadata.selection) { // check whether this menu item should show or not. if (itemData.indexOf(type) !== -1) { shouldHide = false; break; } } return shouldHide; }, /** * Destroy the ConsoleContextMenu object instance. */ destroy: function () { this.popup.removeEventListener("popupshowing", this.build); this.popup = null; this.owner = null; this.lastClickedMessage = null; }, };