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 261315 e556ecd514e0c3204cf13ed50f51aeb039f61a8e
parent 261314 1d9a8a4f79b99172d43215d154d23e40be8861f7
child 261316 de590052e6283a8008dff28de5bca3d01454e8ff
push id15078
push userapoirot@mozilla.com
push dateTue, 08 Sep 2015 16:49:56 +0000
treeherderfx-team@e556ecd514e0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspast
bugs1184172
milestone43.0a1
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