Bug 1184172 - Show stackframe for errors in the webconsole. r=past
authorAlexandre Poirot <poirot.alex@gmail.com>
Tue, 08 Sep 2015 09:48:38 -0700
changeset 261345 e556ecd514e0c3204cf13ed50f51aeb039f61a8e
parent 261344 1d9a8a4f79b99172d43215d154d23e40be8861f7
child 261346 de590052e6283a8008dff28de5bca3d01454e8ff
push id29341
push userkwierso@gmail.com
push dateTue, 08 Sep 2015 23:59:39 +0000
treeherdermozilla-central@02b255caee93 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspast
bugs1184172
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1184172 - Show stackframe for errors in the webconsole. r=past
browser/devtools/webconsole/console-output.js
browser/devtools/webconsole/test/browser.ini
browser/devtools/webconsole/test/browser_console_addonsdk_loader_exception.js
browser/devtools/webconsole/test/browser_console_error_source_click.js
browser/devtools/webconsole/test/browser_webconsole_exception_stackframe.js
browser/devtools/webconsole/test/head.js
browser/devtools/webconsole/test/test-exception-stackframe.html
browser/devtools/webconsole/webconsole.js
toolkit/devtools/server/actors/webconsole.js
--- a/browser/devtools/webconsole/console-output.js
+++ b/browser/devtools/webconsole/console-output.js
@@ -670,38 +670,42 @@ Messages.NavigationMarker.prototype = He
  *        Defaults to |Date.now()|.
  *        - link: (string) if provided, the message will be wrapped in an anchor
  *        pointing to the given URL here.
  *        - linkCallback: (function) if provided, the message will be wrapped in
  *        an anchor. The |linkCallback| function will be added as click event
  *        handler.
  *        - location: object that tells the message source: url, line, column
  *        and lineText.
+ *        - stack: array that tells the message source stack.
  *        - className: (string) additional element class names for styling
  *        purposes.
  *        - private: (boolean) mark this as a private message.
  *        - filterDuplicates: (boolean) true if you do want this message to be
  *        filtered as a potential duplicate message, false otherwise.
  */
 Messages.Simple = function(message, options = {})
 {
   Messages.BaseMessage.call(this);
 
   this.category = options.category;
   this.severity = options.severity;
   this.location = options.location;
+  this.stack    = options.stack;
   this.timestamp = options.timestamp || Date.now();
   this.prefix = options.prefix;
   this.private = !!options.private;
 
   this._message = message;
   this._className = options.className;
   this._link = options.link;
   this._linkCallback = options.linkCallback;
   this._filterDuplicates = options.filterDuplicates;
+
+  this._onClickCollapsible = this._onClickCollapsible.bind(this);
 };
 
 Messages.Simple.prototype = Heritage.extend(Messages.BaseMessage.prototype,
 {
   /**
    * Message category.
    * @type string
    */
@@ -715,16 +719,24 @@ Messages.Simple.prototype = Heritage.ext
 
   /**
    * Message source location. Properties: url, line, column, lineText.
    * @type object
    */
   location: null,
 
   /**
+   * Holds the stackframes received from the server.
+   *
+   * @private
+   * @type array
+   */
+  stack: null,
+
+  /**
    * Message prefix
    * @type string|null
    */
   prefix: null,
 
   /**
    * Tells if this message comes from a private browsing context.
    * @type boolean
@@ -805,16 +817,30 @@ Messages.Simple.prototype = Heritage.ext
   init: function()
   {
     Messages.BaseMessage.prototype.init.apply(this, arguments);
     this._groupDepthCompat = this.output.owner.groupDepth;
     this._initRepeatID();
     return this;
   },
 
+  /**
+   * Tells if the message can be expanded/collapsed.
+   * @type boolean
+   */
+  collapsible: false,
+
+  /**
+   * Getter that tells if this message is collapsed - no details are shown.
+   * @type boolean
+   */
+  get collapsed() {
+    return this.collapsible && this.element && !this.element.hasAttribute("open");
+  },
+
   _initRepeatID: function()
   {
     if (!this._filterDuplicates) {
       return;
     }
 
     // Add the properties we care about for identifying duplicate messages.
     let rid = this._repeatID;
@@ -849,16 +875,19 @@ Messages.Simple.prototype = Heritage.ext
       return this;
     }
 
     let timestamp = new Widgets.MessageTimestamp(this, this.timestamp).render();
 
     let icon = this.document.createElementNS(XHTML_NS, "span");
     icon.className = "icon";
     icon.title = l10n.getStr("severity." + this._severityNameCompat);
+    if (this.stack) {
+      icon.addEventListener("click", this._onClickCollapsible);
+    }
 
     let prefixNode;
     if (this.prefix) {
       prefixNode = this.document.createElementNS(XHTML_NS, "span");
       prefixNode.className = "prefix devtools-monospace";
       prefixNode.textContent = this.prefix + ":";
     }
 
@@ -881,23 +910,36 @@ Messages.Simple.prototype = Heritage.ext
     }
 
     this.element.appendChild(timestamp.element);
     this.element.appendChild(indentNode);
     this.element.appendChild(icon);
     if (prefixNode) {
       this.element.appendChild(prefixNode);
     }
+
+    if (this.stack) {
+      let twisty = this.document.createElementNS(XHTML_NS, "a");
+      twisty.className = "theme-twisty";
+      twisty.href = "#";
+      twisty.title = l10n.getStr("messageToggleDetails");
+      twisty.addEventListener("click", this._onClickCollapsible);
+      this.element.appendChild(twisty);
+      this.collapsible = true;
+      this.element.setAttribute("collapsible", true);
+    }
+
     this.element.appendChild(body);
     if (repeatNode) {
       this.element.appendChild(repeatNode);
     }
     if (location) {
       this.element.appendChild(location);
     }
+
     this.element.appendChild(this.document.createTextNode("\n"));
 
     this.element.clipboardText = this.element.textContent;
 
     if (this.private) {
       this.element.setAttribute("private", true);
     }
 
@@ -939,16 +981,22 @@ Messages.Simple.prototype = Heritage.ext
     if (typeof this._message == "function") {
       container.appendChild(this._message(this));
     } else if (this._message instanceof Ci.nsIDOMNode) {
       container.appendChild(this._message);
     } else {
       container.textContent = this._message;
     }
 
+    if (this.stack) {
+      let stack = new Widgets.Stacktrace(this, this.stack).render().element;
+      body.appendChild(this.document.createTextNode("\n"));
+      body.appendChild(stack);
+    }
+
     return body;
   },
 
   /**
    * Render the repeat bubble DOM element part of the message.
    * @private
    * @return Element
    */
@@ -983,16 +1031,46 @@ Messages.Simple.prototype = Heritage.ext
     }
 
     // The ConsoleOutput owner is a WebConsoleFrame instance from webconsole.js.
     // TODO: move createLocationNode() into this file when bug 778766 is fixed.
     return this.output.owner.createLocationNode({url: url,
                                                  line: line,
                                                  column: column});
   },
+
+  /**
+   * The click event handler for the message expander arrow element. This method
+   * toggles the display of message details.
+   *
+   * @private
+   * @param nsIDOMEvent ev
+   *        The DOM event object.
+   * @see this.toggleDetails()
+   */
+  _onClickCollapsible: function(ev)
+  {
+    ev.preventDefault();
+    this.toggleDetails();
+  },
+
+  /**
+   * Expand/collapse message details.
+   */
+  toggleDetails: function()
+  {
+    let twisty = this.element.querySelector(".theme-twisty");
+    if (this.element.hasAttribute("open")) {
+      this.element.removeAttribute("open");
+      twisty.removeAttribute("open");
+    } else {
+      this.element.setAttribute("open", true);
+      twisty.setAttribute("open", true);
+    }
+  },
 }); // Messages.Simple.prototype
 
 
 /**
  * The Extended message.
  *
  * @constructor
  * @extends Messages.Simple
@@ -1325,40 +1403,23 @@ Messages.ConsoleGeneric = function(packe
     }
     default:
       Messages.Extended.call(this, packet.arguments, options);
       break;
   }
 
   this._repeatID.consoleApiLevel = packet.level;
   this._repeatID.styles = packet.styles;
-  this._stacktrace = this._repeatID.stacktrace = packet.stacktrace;
+  this.stack = this._repeatID.stacktrace = packet.stacktrace;
   this._styles = packet.styles || [];
-
-  this._onClickCollapsible = this._onClickCollapsible.bind(this);
 };
 
 Messages.ConsoleGeneric.prototype = Heritage.extend(Messages.Extended.prototype,
 {
   _styles: null,
-  _stacktrace: null,
-
-  /**
-   * Tells if the message can be expanded/collapsed.
-   * @type boolean
-   */
-  collapsible: false,
-
-  /**
-   * Getter that tells if this message is collapsed - no details are shown.
-   * @type boolean
-   */
-  get collapsed() {
-    return this.collapsible && this.element && !this.element.hasAttribute("open");
-  },
 
   _renderBodyPieceSeparator: function()
   {
     return this.document.createTextNode(" ");
   },
 
   render: function()
   {
@@ -1368,65 +1429,36 @@ Messages.ConsoleGeneric.prototype = Heri
     this._renderBodyPieces(msg);
 
     let repeatNode = Messages.Simple.prototype._renderRepeatNode.call(this);
     let location = Messages.Simple.prototype._renderLocation.call(this);
     if (location) {
       location.target = "jsdebugger";
     }
 
-    let stack = null;
-    let twisty = null;
-    if (this._stacktrace && this._stacktrace.length > 0) {
-      stack = new Widgets.Stacktrace(this, this._stacktrace).render().element;
-
-      twisty = this.document.createElementNS(XHTML_NS, "a");
-      twisty.className = "theme-twisty";
-      twisty.href = "#";
-      twisty.title = l10n.getStr("messageToggleDetails");
-      twisty.addEventListener("click", this._onClickCollapsible);
-    }
-
     let flex = this.document.createElementNS(XHTML_NS, "span");
     flex.className = "message-flex-body";
 
-    if (twisty) {
-      flex.appendChild(twisty);
-    }
-
     flex.appendChild(msg);
 
     if (repeatNode) {
       flex.appendChild(repeatNode);
     }
     if (location) {
       flex.appendChild(location);
     }
 
     let result = this.document.createDocumentFragment();
     result.appendChild(flex);
 
-    if (stack) {
-      result.appendChild(this.document.createTextNode("\n"));
-      result.appendChild(stack);
-    }
-
     this._message = result;
     this._stacktrace = null;
 
     Messages.Simple.prototype.render.call(this);
 
-    if (stack) {
-      this.collapsible = true;
-      this.element.setAttribute("collapsible", true);
-
-      let icon = this.element.querySelector(".icon");
-      icon.addEventListener("click", this._onClickCollapsible);
-    }
-
     return this;
   },
 
   _renderBody: function()
   {
     let body = Messages.Simple.prototype._renderBody.apply(this, arguments);
     body.classList.remove("devtools-monospace", "message-body");
     return body;
@@ -1480,46 +1512,16 @@ Messages.ConsoleGeneric.prototype = Heri
   },
 
   // no-op for the message location and .repeats elements.
   // |this.render()| handles customized message output.
   _renderLocation: function() { },
   _renderRepeatNode: function() { },
 
   /**
-   * Expand/collapse message details.
-   */
-  toggleDetails: function()
-  {
-    let twisty = this.element.querySelector(".theme-twisty");
-    if (this.element.hasAttribute("open")) {
-      this.element.removeAttribute("open");
-      twisty.removeAttribute("open");
-    } else {
-      this.element.setAttribute("open", true);
-      twisty.setAttribute("open", true);
-    }
-  },
-
-  /**
-   * The click event handler for the message expander arrow element. This method
-   * toggles the display of message details.
-   *
-   * @private
-   * @param nsIDOMEvent ev
-   *        The DOM event object.
-   * @see this.toggleDetails()
-   */
-  _onClickCollapsible: function(ev)
-  {
-    ev.preventDefault();
-    this.toggleDetails();
-  },
-
-  /**
    * Given a style attribute value, return a cleaned up version of the string
    * such that:
    *
    * - no external URL is allowed to load. See RE_CLEANUP_STYLES.
    * - only some of the properties are allowed, based on a whitelist. See
    *   RE_ALLOWED_STYLES.
    *
    * @param string style
--- a/browser/devtools/webconsole/test/browser.ini
+++ b/browser/devtools/webconsole/test/browser.ini
@@ -124,16 +124,17 @@ support-files =
   test-bug_939783_console_trace_duplicates.html
   test-bug-952277-highlight-nodes-in-vview.html
   test-bug-609872-cd-iframe-parent.html
   test-bug-609872-cd-iframe-child.html
   test-bug-989025-iframe-parent.html
   test-bug_1050691_click_function_to_source.html
   test-bug_1050691_click_function_to_source.js
   test-console-api-stackframe.html
+  test-exception-stackframe.html
   test_bug_1010953_cspro.html^headers^
   test_bug_1010953_cspro.html
   test_bug1045902_console_csp_ignore_reflected_xss_message.html^headers^
   test_bug1045902_console_csp_ignore_reflected_xss_message.html
   test_bug1092055_shouldwarn.js^headers^
   test_bug1092055_shouldwarn.js
   test_bug1092055_shouldwarn.html
 
@@ -378,15 +379,16 @@ skip-if = e10s # Bug 1042253 - webconsol
 [browser_webconsole_output_table.js]
 [browser_console_variables_view_highlighter.js]
 [browser_webconsole_start_netmon_first.js]
 [browser_webconsole_console_trace_duplicates.js]
 [browser_webconsole_cd_iframe.js]
 [browser_webconsole_autocomplete_crossdomain_iframe.js]
 [browser_webconsole_console_custom_styles.js]
 [browser_webconsole_console_api_stackframe.js]
+[browser_webconsole_exception_stackframe.js]
 [browser_webconsole_column_numbers.js]
 [browser_console_open_or_focus.js]
 [browser_webconsole_bug_922212_console_dirxml.js]
 [browser_webconsole_shows_reqs_in_netmonitor.js]
 [browser_netmonitor_shows_reqs_in_webconsole.js]
 [browser_webconsole_bug_1050691_click_function_to_source.js]
 [browser_webconsole_context_menu_open_in_var_view.js]
--- a/browser/devtools/webconsole/test/browser_console_addonsdk_loader_exception.js
+++ b/browser/devtools/webconsole/test/browser_console_addonsdk_loader_exception.js
@@ -52,17 +52,17 @@ function test() {
         severity: SEVERITY_ERROR,
       }],
     });
 
     fixToolbox();
 
     let msg = [...result.matched][0];
     ok(msg, "message element found");
-    let locationNode = msg.querySelector(".message-location");
+    let locationNode = msg.querySelector(".message > .message-location");
     ok(locationNode, "message location element found");
 
     let title = locationNode.getAttribute("title");
     info("location node title: " + title);
     isnot(title.indexOf(" -> "), -1, "error comes from a subscript");
 
     let viewSource = browserconsole.viewSource;
     let URL = null;
@@ -73,17 +73,17 @@ function test() {
       clickPromise.resolve(null);
     };
 
     msg.scrollIntoView();
     EventUtils.synthesizeMouse(locationNode, 2, 2, {},
                                browserconsole.iframeWindow);
 
     info("wait for click on locationNode");
-    yield clickPromise;
+    yield clickPromise.promise;
 
     info("view-source url: " + URL);
     ok(URL, "we have some source URL after the click");
     isnot(URL.indexOf("toolbox.js"), -1, "we have the expected view source URL");
     is(URL.indexOf("->"), -1, "no -> in the URL given to view-source");
 
     browserconsole.viewSourceInDebugger = viewSource;
   }
--- a/browser/devtools/webconsole/test/browser_console_error_source_click.js
+++ b/browser/devtools/webconsole/test/browser_console_error_source_click.js
@@ -53,17 +53,17 @@ function test() {
     let viewSourceCalled = false;
     hud.viewSourceInDebugger = () => viewSourceCalled = true;
 
     for (let result of results) {
       viewSourceCalled = false;
 
       let msg = [...results[0].matched][0];
       ok(msg, "message element found for: " + result.text);
-      let locationNode = msg.querySelector(".message-location");
+      let locationNode = msg.querySelector(".message > .message-location");
       ok(locationNode, "message location element found");
 
       EventUtils.synthesizeMouse(locationNode, 2, 2, {}, hud.iframeWindow);
 
       ok(viewSourceCalled, "view source opened");
     }
 
     hud.viewSourceInDebugger = viewSource;
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webconsole/test/browser_webconsole_exception_stackframe.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the console receive exceptions include a stackframe.
+// See bug 1184172.
+
+// On e10s, the exception is triggered in child process
+// and is ignored by test harness
+if (!Services.appinfo.browserTabsRemoteAutostart) {
+  expectUncaughtException();
+}
+
+function test() {
+  let hud;
+
+  const TEST_URI = "http://example.com/browser/browser/devtools/webconsole/" +
+                   "test/test-exception-stackframe.html";
+  const TEST_FILE = TEST_URI.substr(TEST_URI.lastIndexOf("/"));
+
+  Task.spawn(runner).then(finishTest);
+
+  function* runner() {
+    const {tab} = yield loadTab(TEST_URI);
+    hud = yield openConsole(tab);
+
+    const stack = [{
+      file: TEST_FILE,
+      fn: "thirdCall",
+      line: 21,
+    }, {
+      file: TEST_FILE,
+      fn: "secondCall",
+      line: 17,
+    }, {
+      file: TEST_FILE,
+      fn: "firstCall",
+      line: 12,
+    }];
+
+    let results = yield waitForMessages({
+      webconsole: hud,
+      messages: [{
+        text: "nonExistingMethodCall is not defined",
+        category: CATEGORY_JS,
+        severity: SEVERITY_ERROR,
+        collapsible: true,
+        stacktrace: stack,
+      }],
+    });
+
+    let elem = [...results[0].matched][0];
+    ok(elem, "message element");
+
+    let msg = elem._messageObject;
+    ok(msg, "message object");
+
+    ok(msg.collapsed, "message is collapsed");
+
+    msg.toggleDetails();
+
+    ok(!msg.collapsed, "message is not collapsed");
+
+    msg.toggleDetails();
+
+    ok(msg.collapsed, "message is collapsed");
+
+    yield closeConsole(tab);
+  }
+}
--- a/browser/devtools/webconsole/test/head.js
+++ b/browser/devtools/webconsole/test/head.js
@@ -1074,37 +1074,37 @@ function waitForMessages(options) {
         ok(false, "expected frame #" + i + " but didnt find it");
         return false;
       }
 
       if (expected.file) {
         let file = frame.querySelector(".message-location").title;
         if (!checkText(expected.file, file)) {
           ok(false, "frame #" + i + " does not match file name: " +
-                    expected.file);
+                    expected.file + " != " + file);
           displayErrorContext(rule, element);
           return false;
         }
       }
 
       if (expected.fn) {
         let fn = frame.querySelector(".function").textContent;
         if (!checkText(expected.fn, fn)) {
           ok(false, "frame #" + i + " does not match the function name: " +
-                    expected.fn);
+                    expected.fn + " != " + fn);
           displayErrorContext(rule, element);
           return false;
         }
       }
 
       if (expected.line) {
         let line = frame.querySelector(".message-location").sourceLine;
         if (!checkText(expected.line, line)) {
           ok(false, "frame #" + i + " does not match the line number: " +
-                    expected.line);
+                    expected.line + " != " + line);
           displayErrorContext(rule, element);
           return false;
         }
       }
     }
 
     return true;
   }
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webconsole/test/test-exception-stackframe.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html dir="ltr" lang="en">
+  <head>
+    <meta charset="utf8">
+    <!--
+    - Any copyright is dedicated to the Public Domain.
+    - http://creativecommons.org/publicdomain/zero/1.0/
+    -->
+    <title>Test for bug 1184172 - stacktraces for exceptions</title>
+    <script>
+      function firstCall() {
+        secondCall();
+      }
+
+      // Check anonymous functions
+      var secondCall = function () {
+        thirdCall();
+      }
+
+      function thirdCall() {
+        nonExistingMethodCall();
+      }
+
+      window.onload = firstCall;
+    </script>
+  </head>
+  <body>
+    <p>Hello world!</p>
+  </body>
+</html>
--- a/browser/devtools/webconsole/webconsole.js
+++ b/browser/devtools/webconsole/webconsole.js
@@ -1523,16 +1523,17 @@ WebConsoleFrame.prototype = {
 
     // Create a new message
     let msg = new Messages.Simple(errorMessage, {
       location: {
         url: displayOrigin,
         line: aScriptError.lineNumber,
         column: aScriptError.columnNumber
       },
+      stack: aScriptError.stacktrace,
       category: category,
       severity: severity,
       timestamp: aScriptError.timeStamp,
       private: aScriptError.private,
       filterDuplicates: true
     });
 
     let node = msg.init(this.output).render().element;
--- a/toolkit/devtools/server/actors/webconsole.js
+++ b/toolkit/devtools/server/actors/webconsole.js
@@ -1283,16 +1283,31 @@ WebConsoleActor.prototype =
    *
    * @param nsIScriptError aPageError
    *        The page error we need to send to the client.
    * @return object
    *         The object you can send to the remote client.
    */
   preparePageErrorForRemote: function WCA_preparePageErrorForRemote(aPageError)
   {
+    let stack = null;
+    // Convert stack objects to the JSON attributes expected by client code
+    if (aPageError.stack) {
+      stack = [];
+      let s = aPageError.stack;
+      while (s !== null) {
+        stack.push({
+          filename: s.source,
+          lineNumber: s.line,
+          columnNumber: s.column,
+          functionName: s.functionDisplayName
+        });
+        s = s.parent;
+      }
+    }
     let lineText = aPageError.sourceLine;
     if (lineText && lineText.length > DebuggerServer.LONG_STRING_INITIAL_LENGTH) {
       lineText = lineText.substr(0, DebuggerServer.LONG_STRING_INITIAL_LENGTH);
     }
 
     return {
       errorMessage: this._createStringGrip(aPageError.errorMessage),
       sourceName: aPageError.sourceName,
@@ -1302,16 +1317,17 @@ WebConsoleActor.prototype =
       category: aPageError.category,
       timeStamp: aPageError.timeStamp,
       warning: !!(aPageError.flags & aPageError.warningFlag),
       error: !!(aPageError.flags & aPageError.errorFlag),
       exception: !!(aPageError.flags & aPageError.exceptionFlag),
       strict: !!(aPageError.flags & aPageError.strictFlag),
       info: !!(aPageError.flags & aPageError.infoFlag),
       private: aPageError.isFromPrivateWindow,
+      stacktrace: stack
     };
   },
 
   /**
    * Handler for window.console API calls received from the ConsoleAPIListener.
    * This method sends the object to the remote Web Console client.
    *
    * @see ConsoleAPIListener