Merge m-c to inbound. a=merge
authorRyan VanderMeulen <ryanvm@gmail.com>
Fri, 07 Oct 2016 23:08:04 -0400
changeset 317131 5739b943a4ea6ed34548348448f2924d31d74846
parent 317130 63e401d16993f2b17a69c92eabd385d2f2e256fb (current diff)
parent 317025 a835589ae0c63a2d91be150d80b5fc600e44b447 (diff)
child 317132 74b89fbf2130928293daf4c156cc65b3c6d16db5
push id20681
push userphilringnalda@gmail.com
push dateSat, 08 Oct 2016 23:57:20 +0000
treeherderfx-team@7a7ba250bb2f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone52.0a1
Merge m-c to inbound. a=merge
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -81,16 +81,20 @@ timeEnd=%1$S: %2$Sms
 # call to console.clear() to let the user know the previous messages of the
 # console have been removed programmatically.
 consoleCleared=Console was cleared.
 
 # LOCALIZATION NOTE (noCounterLabel): this string is used to display
 # count-messages with no label provided.
 noCounterLabel=<no label>
 
+# LOCALIZATION NOTE (noGroupLabel): this string is used to display
+# console.group messages with no label provided.
+noGroupLabel=<no group label>
+
 # LOCALIZATION NOTE (Autocomplete.blank): this string is used when inputnode
 # string containing anchor doesn't matches to any property in the content.
 Autocomplete.blank=  <- no result
 
 maxTimersExceeded=The maximum allowed number of timers in this page was exceeded.
 
 # LOCALIZATION NOTE (maxCountersExceeded): Error message shown when the maximum
 # number of console.count()-counters was exceeded.
@@ -145,16 +149,20 @@ selfxss.msg=Scam Warning: Take care when pasting things you don’t understand. This could allow attackers to steal your identity or take control of your computer. Please type ‘%S’ below (no need to press enter) to allow pasting.
 # Please avoid using non-keyboard characters here
 selfxss.okstring=allow pasting
 
 # LOCALIZATION NOTE (messageToggleDetails): the text that is displayed when
 # you hover the arrow for expanding/collapsing the message details. For
 # console.error() and other messages we show the stacktrace.
 messageToggleDetails=Show/hide message details.
 
+# LOCALIZATION NOTE (groupToggle): the text that is displayed when
+# you hover the arrow for expanding/collapsing the messages of a group.
+groupToggle=Show/hide group.
+
 # LOCALIZATION NOTE (emptySlotLabel): the text is displayed when an Array
 # with empty slots is printed to the console.
 # This is a semi-colon list of plural forms.
 # See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
 # #1 number of empty slots
 # example: 1 empty slot
 # example: 5 empty slots
 emptySlotLabel=#1 empty slot;#1 empty slots
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -704,32 +704,59 @@ a.learn-more-link.webconsole-learn-more-
 .webconsole-filterbar-primary {
   display: flex;
 }
 
 .webconsole-filterbar-primary .devtools-plaininput {
   flex: 1 1 100%;
 }
 
+.message.startGroup .message-body,
+.message.startGroupCollapsed .message-body {
+  color: var(--theme-body-color);
+  font-weight: bold;
+}
+
+.webconsole-output-wrapper .message > .icon {
+  margin: 3px 0 0 0;
+  padding: 0 0 0 6px;
+}
+
 .message.error > .icon::before {
   background-position: -12px -36px;
 }
 
 .message.warn > .icon::before {
   background-position: -24px -36px;
 }
 
 .message.info > .icon::before {
   background-position: -36px -36px;
 }
 
 .message.network .method {
   margin-inline-end: 5px;
 }
 
+.webconsole-output-wrapper .message .indent {
+  display: inline-block;
+  border-inline-end: solid 1px var(--theme-splitter-color);
+}
+
+.message.startGroup .indent,
+.message.startGroupCollapsed .indent {
+  border-inline-end-color: transparent;
+  margin-inline-end: 5px;
+}
+
+.message.startGroup .icon,
+.message.startGroupCollapsed .icon {
+    display: none;
+}
+
 /* console.table() */
 .new-consoletable {
   width: 100%;
   border-collapse: collapse;
   --consoletable-border: 1px solid var(--table-splitter-color);
 }
 
 .new-consoletable thead,
--- a/devtools/client/webconsole/new-console-output/actions/messages.js
+++ b/devtools/client/webconsole/new-console-output/actions/messages.js
@@ -92,8 +92,9 @@ function messageTableDataReceive(id, dat
 
 module.exports = {
   messageAdd,
   messagesClear,
   messageOpen,
   messageClose,
   messageTableDataGet,
 };
+
--- a/devtools/client/webconsole/new-console-output/components/collapse-button.js
+++ b/devtools/client/webconsole/new-console-output/components/collapse-button.js
@@ -16,28 +16,35 @@ const {
 const { l10n } = require("devtools/client/webconsole/new-console-output/utils/messages");
 
 const CollapseButton = createClass({
 
   displayName: "CollapseButton",
 
   propTypes: {
     open: PropTypes.bool.isRequired,
+    title: PropTypes.string,
+  },
+
+  getDefaultProps: function () {
+    return {
+      title: l10n.getStr("messageToggleDetails")
+    };
   },
 
   render: function () {
-    const { open, onClick } = this.props;
+    const { open, onClick, title } = this.props;
 
     let classes = ["theme-twisty"];
 
     if (open) {
       classes.push("open");
     }
 
     return dom.a({
       className: classes.join(" "),
       onClick,
-      title: l10n.getStr("messageToggleDetails"),
+      title: title,
     });
   }
 });
 
 module.exports = CollapseButton;
--- a/devtools/client/webconsole/new-console-output/components/console-output.js
+++ b/devtools/client/webconsole/new-console-output/components/console-output.js
@@ -7,17 +7,22 @@ const {
   createClass,
   createFactory,
   DOM: dom,
   PropTypes
 } = require("devtools/client/shared/vendor/react");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 
-const { getAllMessages, getAllMessagesUiById, getAllMessagesTableDataById } = require("devtools/client/webconsole/new-console-output/selectors/messages");
+const {
+  getAllMessages,
+  getAllMessagesUiById,
+  getAllMessagesTableDataById,
+  getAllGroupsById,
+} = require("devtools/client/webconsole/new-console-output/selectors/messages");
 const { getScrollSetting } = require("devtools/client/webconsole/new-console-output/selectors/ui");
 const MessageContainer = createFactory(require("devtools/client/webconsole/new-console-output/components/message-container").MessageContainer);
 
 const ConsoleOutput = createClass({
 
   displayName: "ConsoleOutput",
 
   propTypes: {
@@ -57,28 +62,35 @@ const ConsoleOutput = createClass({
   render() {
     let {
       dispatch,
       autoscroll,
       messages,
       messagesUi,
       messagesTableData,
       serviceContainer,
+      groups,
     } = this.props;
 
     let messageNodes = messages.map((message) => {
+      const parentGroups = message.groupId ? (
+        (groups.get(message.groupId) || [])
+          .concat([message.groupId])
+      ) : [];
+
       return (
         MessageContainer({
           dispatch,
           message,
           key: message.id,
           serviceContainer,
           open: messagesUi.includes(message.id),
           tableData: messagesTableData.get(message.id),
           autoscroll,
+          indent: parentGroups.length,
         })
       );
     });
     return (
       dom.div({
         className: "webconsole-output",
         ref: node => {
           this.outputNode = node;
@@ -95,18 +107,19 @@ function scrollToBottom(node) {
 
 function isScrolledToBottom(outputNode, scrollNode) {
   let lastNodeHeight = outputNode.lastChild ?
                        outputNode.lastChild.clientHeight : 0;
   return scrollNode.scrollTop + scrollNode.clientHeight >=
          scrollNode.scrollHeight - lastNodeHeight / 2;
 }
 
-function mapStateToProps(state) {
+function mapStateToProps(state, props) {
   return {
     messages: getAllMessages(state),
     messagesUi: getAllMessagesUiById(state),
     messagesTableData: getAllMessagesTableDataById(state),
     autoscroll: getScrollSetting(state),
+    groups: getAllGroupsById(state),
   };
 }
 
 module.exports = connect(mapStateToProps)(ConsoleOutput);
--- a/devtools/client/webconsole/new-console-output/components/message-container.js
+++ b/devtools/client/webconsole/new-console-output/components/message-container.js
@@ -30,21 +30,23 @@ const componentMap = new Map([
 const MessageContainer = createClass({
   displayName: "MessageContainer",
 
   propTypes: {
     message: PropTypes.object.isRequired,
     open: PropTypes.bool.isRequired,
     serviceContainer: PropTypes.object.isRequired,
     autoscroll: PropTypes.bool.isRequired,
+    indent: PropTypes.number.isRequired,
   },
 
   getDefaultProps: function () {
     return {
-      open: false
+      open: false,
+      indent: 0,
     };
   },
 
   shouldComponentUpdate(nextProps, nextState) {
     const repeatChanged = this.props.message.repeat !== nextProps.message.repeat;
     const openChanged = this.props.open !== nextProps.open;
     const tableDataChanged = this.props.tableData !== nextProps.tableData;
     return repeatChanged || openChanged || tableDataChanged;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/components/message-indent.js
@@ -0,0 +1,37 @@
+/* -*- 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";
+
+// React & Redux
+const {
+  createClass,
+  DOM: dom,
+  PropTypes,
+} = require("devtools/client/shared/vendor/react");
+
+const INDENT_WIDTH = 12;
+const MessageIndent = createClass({
+
+  displayName: "MessageIndent",
+
+  propTypes: {
+    indent: PropTypes.number.isRequired,
+  },
+
+  render: function () {
+    const { indent } = this.props;
+    return dom.span({
+      className: "indent",
+      style: {"width": indent * INDENT_WIDTH}
+    });
+  }
+});
+
+module.exports.MessageIndent = MessageIndent;
+
+// Exported so we can test it with unit tests.
+module.exports.INDENT_WIDTH = INDENT_WIDTH;
--- a/devtools/client/webconsole/new-console-output/components/message-types/console-api-call.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/console-api-call.js
@@ -9,91 +9,105 @@
 // React & Redux
 const {
   createFactory,
   DOM: dom,
   PropTypes
 } = require("devtools/client/shared/vendor/react");
 const GripMessageBody = createFactory(require("devtools/client/webconsole/new-console-output/components/grip-message-body"));
 const ConsoleTable = createFactory(require("devtools/client/webconsole/new-console-output/components/console-table"));
+const {isGroupType, l10n} = require("devtools/client/webconsole/new-console-output/utils/messages");
 
 const Message = createFactory(require("devtools/client/webconsole/new-console-output/components/message"));
 
 ConsoleApiCall.displayName = "ConsoleApiCall";
 
 ConsoleApiCall.propTypes = {
   message: PropTypes.object.isRequired,
   open: PropTypes.bool,
   serviceContainer: PropTypes.object.isRequired,
+  indent: PropTypes.number.isRequired,
 };
 
 ConsoleApiCall.defaultProps = {
-  open: false
+  open: false,
+  indent: 0,
 };
 
 function ConsoleApiCall(props) {
   const {
     dispatch,
     message,
     open,
     tableData,
     serviceContainer,
+    indent,
   } = props;
   const {
     id: messageId,
     source, type,
     level,
     repeat,
     stacktrace,
     frame,
-    parameters
+    parameters,
+    messageText,
   } = message;
 
   let messageBody;
   if (type === "trace") {
     messageBody = dom.span({className: "cm-variable"}, "console.trace()");
   } else if (type === "assert") {
     let reps = formatReps(parameters);
     messageBody = dom.span({ className: "cm-variable" }, "Assertion failed: ", reps);
   } else if (type === "table") {
     // TODO: Chrome does not output anything, see if we want to keep this
     messageBody = dom.span({className: "cm-variable"}, "console.table()");
   } else if (parameters) {
     messageBody = formatReps(parameters);
   } else {
-    messageBody = message.messageText;
+    messageBody = messageText;
   }
 
   let attachment = null;
   if (type === "table") {
     attachment = ConsoleTable({
       dispatch,
       id: message.id,
       serviceContainer,
       parameters: message.parameters,
       tableData
     });
   }
 
+  let collapseTitle = null;
+  if (isGroupType(type)) {
+    collapseTitle = l10n.getStr("groupToggle");
+  }
+
+  const collapsible = attachment !== null || isGroupType(type);
   const topLevelClasses = ["cm-s-mozilla"];
 
   return Message({
     messageId,
     open,
+    collapsible,
+    collapseTitle,
     source,
     type,
     level,
     topLevelClasses,
     messageBody,
     repeat,
     frame,
     stacktrace,
     attachment,
     serviceContainer,
     dispatch,
+    indent,
   });
 }
 
 function formatReps(parameters) {
   return (
     parameters
       // Get all the grips.
       .map((grip, key) => GripMessageBody({ grip, key }))
@@ -102,8 +116,9 @@ function formatReps(parameters) {
         return i + 1 < parameters.length
           ? arr.concat(v, dom.span({}, " "))
           : arr.concat(v);
       }, [])
   );
 }
 
 module.exports = ConsoleApiCall;
+
--- a/devtools/client/webconsole/new-console-output/components/message-types/console-command.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/console-command.js
@@ -13,38 +13,45 @@ const {
 } = require("devtools/client/shared/vendor/react");
 const Message = createFactory(require("devtools/client/webconsole/new-console-output/components/message"));
 
 ConsoleCommand.displayName = "ConsoleCommand";
 
 ConsoleCommand.propTypes = {
   message: PropTypes.object.isRequired,
   autoscroll: PropTypes.bool.isRequired,
+  indent: PropTypes.number.isRequired,
+};
+
+ConsoleCommand.defaultProps = {
+  indent: 0,
 };
 
 /**
  * Displays input from the console.
  */
 function ConsoleCommand(props) {
+  const { autoscroll, indent, message } = props;
   const {
     source,
     type,
     level,
     messageText: messageBody,
-  } = props.message;
+  } = message;
 
   const {
     serviceContainer,
   } = props;
 
   const childProps = {
     source,
     type,
     level,
     topLevelClasses: [],
     messageBody,
-    scrollToMessage: props.autoscroll,
+    scrollToMessage: autoscroll,
     serviceContainer,
+    indent: indent,
   };
   return Message(childProps);
 }
 
 module.exports = ConsoleCommand;
--- a/devtools/client/webconsole/new-console-output/components/message-types/evaluation-result.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/evaluation-result.js
@@ -13,20 +13,25 @@ const {
 } = require("devtools/client/shared/vendor/react");
 const Message = createFactory(require("devtools/client/webconsole/new-console-output/components/message"));
 const GripMessageBody = createFactory(require("devtools/client/webconsole/new-console-output/components/grip-message-body"));
 
 EvaluationResult.displayName = "EvaluationResult";
 
 EvaluationResult.propTypes = {
   message: PropTypes.object.isRequired,
+  indent: PropTypes.number.isRequired,
+};
+
+EvaluationResult.defaultProps = {
+  indent: 0,
 };
 
 function EvaluationResult(props) {
-  const { message, serviceContainer } = props;
+  const { message, serviceContainer, indent } = props;
   const {
     source,
     type,
     level,
     id: messageId,
   } = message;
 
   let messageBody;
@@ -37,16 +42,17 @@ function EvaluationResult(props) {
   }
 
   const topLevelClasses = ["cm-s-mozilla"];
 
   const childProps = {
     source,
     type,
     level,
+    indent,
     topLevelClasses,
     messageBody,
     messageId,
     scrollToMessage: props.autoscroll,
     serviceContainer,
   };
   return Message(childProps);
 }
--- a/devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
@@ -17,20 +17,25 @@ const { l10n } = require("devtools/clien
 
 NetworkEventMessage.displayName = "NetworkEventMessage";
 
 NetworkEventMessage.propTypes = {
   message: PropTypes.object.isRequired,
   serviceContainer: PropTypes.shape({
     openNetworkPanel: PropTypes.func.isRequired,
   }),
+  indent: PropTypes.number.isRequired,
+};
+
+NetworkEventMessage.defaultProps = {
+  indent: 0,
 };
 
 function NetworkEventMessage(props) {
-  const { message, serviceContainer } = props;
+  const { message, serviceContainer, indent } = props;
   const { actor, source, type, level, request, isXHR } = message;
 
   const topLevelClasses = [ "cm-s-mozilla" ];
 
   function onUrlClick() {
     serviceContainer.openNetworkPanel(actor);
   }
 
@@ -42,16 +47,17 @@ function NetworkEventMessage(props) {
         request.url.replace(/\?.+/, ""));
 
   const messageBody = dom.span({}, method, xhr, url);
 
   const childProps = {
     source,
     type,
     level,
+    indent,
     topLevelClasses,
     messageBody,
     serviceContainer,
   };
   return Message(childProps);
 }
 
 module.exports = NetworkEventMessage;
--- a/devtools/client/webconsole/new-console-output/components/message-types/page-error.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/page-error.js
@@ -13,46 +13,51 @@ const {
 } = require("devtools/client/shared/vendor/react");
 const Message = createFactory(require("devtools/client/webconsole/new-console-output/components/message"));
 
 PageError.displayName = "PageError";
 
 PageError.propTypes = {
   message: PropTypes.object.isRequired,
   open: PropTypes.bool,
+  indent: PropTypes.number.isRequired,
 };
 
 PageError.defaultProps = {
-  open: false
+  open: false,
+  indent: 0,
 };
 
 function PageError(props) {
   const {
     message,
     open,
     serviceContainer,
+    indent,
   } = props;
   const {
     id: messageId,
     source,
     type,
     level,
     messageText: messageBody,
     repeat,
     stacktrace,
     frame
   } = message;
 
   const childProps = {
     messageId,
     open,
+    collapsible: true,
     source,
     type,
     level,
     topLevelClasses: [],
+    indent,
     messageBody,
     repeat,
     frame,
     stacktrace,
     serviceContainer,
   };
   return Message(childProps);
 }
--- a/devtools/client/webconsole/new-console-output/components/message.js
+++ b/devtools/client/webconsole/new-console-output/components/message.js
@@ -10,44 +10,54 @@
 const {
   createClass,
   createFactory,
   DOM: dom,
   PropTypes
 } = require("devtools/client/shared/vendor/react");
 const actions = require("devtools/client/webconsole/new-console-output/actions/index");
 const CollapseButton = createFactory(require("devtools/client/webconsole/new-console-output/components/collapse-button"));
+const MessageIndent = createFactory(require("devtools/client/webconsole/new-console-output/components/message-indent").MessageIndent);
 const MessageIcon = createFactory(require("devtools/client/webconsole/new-console-output/components/message-icon"));
 const MessageRepeat = createFactory(require("devtools/client/webconsole/new-console-output/components/message-repeat"));
 const FrameView = createFactory(require("devtools/client/shared/components/frame"));
 const StackTrace = createFactory(require("devtools/client/shared/components/stack-trace"));
 
 const Message = createClass({
   displayName: "Message",
 
   propTypes: {
     open: PropTypes.bool,
+    collapsible: PropTypes.bool,
+    collapseTitle: PropTypes.string,
     source: PropTypes.string.isRequired,
     type: PropTypes.string.isRequired,
     level: PropTypes.string.isRequired,
+    indent: PropTypes.number.isRequired,
     topLevelClasses: PropTypes.array.isRequired,
     messageBody: PropTypes.any.isRequired,
     repeat: PropTypes.any,
     frame: PropTypes.any,
     attachment: PropTypes.any,
     stacktrace: PropTypes.any,
     messageId: PropTypes.string,
     scrollToMessage: PropTypes.bool,
     serviceContainer: PropTypes.shape({
       emitNewMessage: PropTypes.func.isRequired,
       onViewSourceInDebugger: PropTypes.func.isRequired,
       sourceMapService: PropTypes.any,
     }),
   },
 
+  getDefaultProps: function () {
+    return {
+      indent: 0
+    };
+  },
+
   componentDidMount() {
     if (this.messageNode) {
       if (this.props.scrollToMessage) {
         this.messageNode.scrollIntoView();
       }
       // Event used in tests. Some message types don't pass it in because existing tests
       // did not emit for them.
       if (this.props.serviceContainer) {
@@ -55,19 +65,22 @@ const Message = createClass({
       }
     }
   },
 
   render() {
     const {
       messageId,
       open,
+      collapsible,
+      collapseTitle,
       source,
       type,
       level,
+      indent,
       topLevelClasses,
       messageBody,
       frame,
       stacktrace,
       serviceContainer,
       dispatch,
     } = this.props;
 
@@ -87,19 +100,20 @@ const Message = createClass({
         stacktrace: stacktrace,
         onViewSourceInDebugger: serviceContainer.onViewSourceInDebugger
       }) : null;
       attachment = dom.div({ className: "stacktrace devtools-monospace" }, child);
     }
 
     // If there is an expandable part, make it collapsible.
     let collapse = null;
-    if (attachment) {
+    if (collapsible) {
       collapse = CollapseButton({
         open,
+        title: collapseTitle,
         onClick: function () {
           if (open) {
             dispatch(actions.messageClose(messageId));
           } else {
             dispatch(actions.messageOpen(messageId));
           }
         },
       });
@@ -120,17 +134,17 @@ const Message = createClass({
 
     return dom.div({
       className: topLevelClasses.join(" "),
       ref: node => {
         this.messageNode = node;
       }
     },
       // @TODO add timestamp
-      // @TODO add indent if necessary
+      MessageIndent({indent}),
       icon,
       collapse,
       dom.span({ className: "message-body-wrapper" },
         dom.span({ className: "message-flex-body" },
           dom.span({ className: "message-body devtools-monospace" },
             messageBody
           ),
           repeat,
--- a/devtools/client/webconsole/new-console-output/components/moz.build
+++ b/devtools/client/webconsole/new-console-output/components/moz.build
@@ -11,12 +11,13 @@ DevToolsModules(
     'collapse-button.js',
     'console-output.js',
     'console-table.js',
     'filter-bar.js',
     'filter-button.js',
     'grip-message-body.js',
     'message-container.js',
     'message-icon.js',
+    'message-indent.js',
     'message-repeat.js',
     'message.js',
     'variables-view-link.js'
 )
--- a/devtools/client/webconsole/new-console-output/reducers/messages.js
+++ b/devtools/client/webconsole/new-console-output/reducers/messages.js
@@ -2,64 +2,134 @@
 /* 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 Immutable = require("devtools/client/shared/vendor/immutable");
 const constants = require("devtools/client/webconsole/new-console-output/constants");
+const {isGroupType} = require("devtools/client/webconsole/new-console-output/utils/messages");
 
 const MessageState = Immutable.Record({
+  // List of all the messages added to the console.
   messagesById: Immutable.List(),
+  // List of the message ids which are opened.
   messagesUiById: Immutable.List(),
+  // Map of the form {messageId : tableData}, which represent the data passed
+  // as an argument in console.table calls.
   messagesTableDataById: Immutable.Map(),
+  // Map of the form {groupMessageId : groupArray},
+  // where groupArray is the list of of all the parent groups' ids of the groupMessageId.
+  groupsById: Immutable.Map(),
+  // Message id of the current group (no corresponding console.groupEnd yet).
+  currentGroup: null,
 });
 
 function messages(state = new MessageState(), action) {
-  const messagesById = state.messagesById;
-  const messagesUiById = state.messagesUiById;
-  const messagesTableDataById = state.messagesTableDataById;
+  const {
+    messagesById,
+    messagesUiById,
+    messagesTableDataById,
+    groupsById,
+    currentGroup
+  } = state;
 
   switch (action.type) {
     case constants.MESSAGE_ADD:
       let newMessage = action.message;
 
       if (newMessage.type === constants.MESSAGE_TYPE.NULL_MESSAGE) {
+        // When the message has a NULL type, we don't add it.
         return state;
       }
 
+      if (newMessage.type === constants.MESSAGE_TYPE.END_GROUP) {
+        // Compute the new current group.
+        return state.set("currentGroup", getNewCurrentGroup(currentGroup, groupsById));
+      }
+
       if (newMessage.allowRepeating && messagesById.size > 0) {
         let lastMessage = messagesById.last();
         if (lastMessage.repeatId === newMessage.repeatId) {
           return state.withMutations(function (record) {
             record.set("messagesById", messagesById.pop().push(
               newMessage.set("repeat", lastMessage.repeat + 1)
             ));
           });
         }
       }
 
       return state.withMutations(function (record) {
-        record.set("messagesById", messagesById.push(newMessage));
+        // Add the new message with a reference to the parent group.
+        record.set(
+          "messagesById",
+          messagesById.push(newMessage.set("groupId", currentGroup))
+        );
+
         if (newMessage.type === "trace") {
+          // We want the stacktrace to be open by default.
           record.set("messagesUiById", messagesUiById.push(newMessage.id));
+        } else if (isGroupType(newMessage.type)) {
+          record.set("currentGroup", newMessage.id);
+          record.set("groupsById",
+            groupsById.set(
+              newMessage.id,
+              getParentGroups(currentGroup, groupsById)
+            )
+          );
+
+          if (newMessage.type === constants.MESSAGE_TYPE.START_GROUP) {
+            // We want the group to be open by default.
+            record.set("messagesUiById", messagesUiById.push(newMessage.id));
+          }
         }
       });
     case constants.MESSAGES_CLEAR:
       return state.withMutations(function (record) {
         record.set("messagesById", Immutable.List());
         record.set("messagesUiById", Immutable.List());
+        record.set("groupsById", Immutable.Map());
+        record.set("currentGroup", null);
       });
     case constants.MESSAGE_OPEN:
       return state.set("messagesUiById", messagesUiById.push(action.id));
     case constants.MESSAGE_CLOSE:
       let index = state.messagesUiById.indexOf(action.id);
       return state.deleteIn(["messagesUiById", index]);
     case constants.MESSAGE_TABLE_RECEIVE:
       const {id, data} = action;
       return state.set("messagesTableDataById", messagesTableDataById.set(id, data));
   }
 
   return state;
 }
 
+function getNewCurrentGroup(currentGoup, groupsById) {
+  let newCurrentGroup = null;
+  if (currentGoup) {
+    // Retrieve the parent groups of the current group.
+    let parents = groupsById.get(currentGoup);
+    if (Array.isArray(parents) && parents.length > 0) {
+      // If there's at least one parent, make the first one the new currentGroup.
+      newCurrentGroup = parents[0];
+    }
+  }
+  return newCurrentGroup;
+}
+
+function getParentGroups(currentGroup, groupsById) {
+  let groups = [];
+  if (currentGroup) {
+    // If there is a current group, we add it as a parent
+    groups = [currentGroup];
+
+    // As well as all its parents, if it has some.
+    let parentGroups = groupsById.get(currentGroup);
+    if (Array.isArray(parentGroups) && parentGroups.length > 0) {
+      groups = groups.concat(parentGroups);
+    }
+  }
+
+  return groups;
+}
+
 exports.messages = messages;
--- a/devtools/client/webconsole/new-console-output/selectors/messages.js
+++ b/devtools/client/webconsole/new-console-output/selectors/messages.js
@@ -9,98 +9,129 @@ const { l10n } = require("devtools/clien
 const { getAllFilters } = require("devtools/client/webconsole/new-console-output/selectors/filters");
 const { getLogLimit } = require("devtools/client/webconsole/new-console-output/selectors/prefs");
 const {
   MESSAGE_TYPE,
   MESSAGE_SOURCE
 } = require("devtools/client/webconsole/new-console-output/constants");
 
 function getAllMessages(state) {
-  let messages = state.messages.messagesById;
+  let messages = getAllMessagesById(state);
   let logLimit = getLogLimit(state);
   let filters = getAllFilters(state);
 
+  let groups = getAllGroupsById(state);
+  let messagesUI = getAllMessagesUiById(state);
+
   return prune(
-    search(
-      filterNetwork(
-        filterLevel(messages, filters),
-        filters
-      ),
-      filters.text
-    ),
+    messages.filter(message => {
+      return (
+        isInOpenedGroup(message, groups, messagesUI)
+        && (
+          isUnfilterable(message)
+          || (
+            matchLevelFilters(message, filters)
+            && matchNetworkFilters(message, filters)
+            && matchSearchFilters(message, filters)
+          )
+        )
+      );
+    }),
     logLimit
   );
 }
 
+function getAllMessagesById(state) {
+  return state.messages.messagesById;
+}
+
 function getAllMessagesUiById(state) {
   return state.messages.messagesUiById;
 }
 
 function getAllMessagesTableDataById(state) {
   return state.messages.messagesTableDataById;
 }
 
-function filterLevel(messages, filters) {
-  return messages.filter((message) => {
-    return filters.get(message.level) === true
-      || [MESSAGE_TYPE.COMMAND, MESSAGE_TYPE.RESULT].includes(message.type);
-  });
+function getAllGroupsById(state) {
+  return state.messages.groupsById;
+}
+
+function getCurrentGroup(state) {
+  return state.messages.currentGroup;
+}
+
+function isUnfilterable(message) {
+  return [
+    MESSAGE_TYPE.COMMAND,
+    MESSAGE_TYPE.RESULT,
+    MESSAGE_TYPE.START_GROUP,
+    MESSAGE_TYPE.START_GROUP_COLLAPSED,
+  ].includes(message.type);
 }
 
-function filterNetwork(messages, filters) {
-  return messages.filter((message) => {
-    return (
-      message.source !== MESSAGE_SOURCE.NETWORK
-      || (filters.get("net") === true && message.isXHR === false)
-      || (filters.get("netxhr") === true && message.isXHR === true)
-      || [MESSAGE_TYPE.COMMAND, MESSAGE_TYPE.RESULT].includes(message.type)
+function isInOpenedGroup(message, groups, messagesUI) {
+  return !message.groupId
+    || (
+      !isGroupClosed(message.groupId, messagesUI)
+      && !hasClosedParentGroup(groups.get(message.groupId), messagesUI)
     );
-  });
+}
+
+function hasClosedParentGroup(group, messagesUI) {
+  return group.some(groupId => isGroupClosed(groupId, messagesUI));
+}
+
+function isGroupClosed(groupId, messagesUI) {
+  return messagesUI.includes(groupId) === false;
+}
+
+function matchLevelFilters(message, filters) {
+  return filters.get(message.level) === true;
 }
 
-function search(messages, text = "") {
-  if (text === "") {
-    return messages;
-  }
-
-  return messages.filter(function (message) {
-    // Evaluation Results and Console Commands are never filtered.
-    if ([ MESSAGE_TYPE.RESULT, MESSAGE_TYPE.COMMAND ].includes(message.type)) {
-      return true;
-    }
+function matchNetworkFilters(message, filters) {
+  return (
+    message.source !== MESSAGE_SOURCE.NETWORK
+    || (filters.get("net") === true && message.isXHR === false)
+    || (filters.get("netxhr") === true && message.isXHR === true)
+  );
+}
 
-    return (
-      // @TODO currently we return true for any object grip. We should find a way to
-      // search object grips.
-      message.parameters !== null && !Array.isArray(message.parameters)
-      // Look for a match in location.
-      || isTextInFrame(text, message.frame)
-      // Look for a match in stacktrace.
-      || (
-        Array.isArray(message.stacktrace) &&
-        message.stacktrace.some(frame => isTextInFrame(text,
-          // isTextInFrame expect the properties of the frame object to be in the same
-          // order they are rendered in the Frame component.
-          {
-            functionName: frame.functionName ||
-              l10n.getStr("stacktrace.anonymousFunction"),
-            filename: frame.filename,
-            lineNumber: frame.lineNumber,
-            columnNumber: frame.columnNumber
-          }))
-      )
-      // Look for a match in messageText.
-      || (message.messageText !== null
-            && message.messageText.toLocaleLowerCase().includes(text.toLocaleLowerCase()))
-      // Look for a match in parameters. Currently only checks value grips.
-      || (message.parameters !== null
-          && message.parameters.join("").toLocaleLowerCase()
-              .includes(text.toLocaleLowerCase()))
-    );
-  });
+function matchSearchFilters(message, filters) {
+  let text = filters.text || "";
+  return (
+    text === ""
+    // @TODO currently we return true for any object grip. We should find a way to
+    // search object grips.
+    || (message.parameters !== null && !Array.isArray(message.parameters))
+    // Look for a match in location.
+    || isTextInFrame(text, message.frame)
+    // Look for a match in stacktrace.
+    || (
+      Array.isArray(message.stacktrace) &&
+      message.stacktrace.some(frame => isTextInFrame(text,
+        // isTextInFrame expect the properties of the frame object to be in the same
+        // order they are rendered in the Frame component.
+        {
+          functionName: frame.functionName ||
+            l10n.getStr("stacktrace.anonymousFunction"),
+          filename: frame.filename,
+          lineNumber: frame.lineNumber,
+          columnNumber: frame.columnNumber
+        }))
+    )
+    // Look for a match in messageText.
+    || (message.messageText !== null
+          && message.messageText.toLocaleLowerCase().includes(text.toLocaleLowerCase()))
+    // Look for a match in parameters. Currently only checks value grips.
+    || (message.parameters !== null
+        && message.parameters.join("").toLocaleLowerCase()
+            .includes(text.toLocaleLowerCase()))
+  );
 }
 
 function isTextInFrame(text, frame) {
   if (!frame) {
     return false;
   }
   // @TODO Change this to Object.values once it's supported in Node's version of V8
   return Object.keys(frame)
@@ -108,17 +139,30 @@ function isTextInFrame(text, frame) {
     .join(":")
     .toLocaleLowerCase()
     .includes(text.toLocaleLowerCase());
 }
 
 function prune(messages, logLimit) {
   let messageCount = messages.count();
   if (messageCount > logLimit) {
-    return messages.splice(0, messageCount - logLimit);
+    // If the second non-pruned message is in a group,
+    // we want to return the group as the first non-pruned message.
+    let firstIndex = messages.size - logLimit;
+    let groupId = messages.get(firstIndex + 1).groupId;
+
+    if (groupId) {
+      return messages.splice(0, firstIndex + 1)
+        .unshift(
+          messages.findLast((message) => message.id === groupId)
+        );
+    }
+    return messages.splice(0, firstIndex);
   }
 
   return messages;
 }
 
 exports.getAllMessages = getAllMessages;
 exports.getAllMessagesUiById = getAllMessagesUiById;
 exports.getAllMessagesTableDataById = getAllMessagesTableDataById;
+exports.getAllGroupsById = getAllGroupsById;
+exports.getCurrentGroup = getCurrentGroup;
--- a/devtools/client/webconsole/new-console-output/test/components/console-api-call.test.js
+++ b/devtools/client/webconsole/new-console-output/test/components/console-api-call.test.js
@@ -1,21 +1,29 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
 // Test utils.
 const expect = require("expect");
-const { render } = require("enzyme");
+const { render, mount } = require("enzyme");
+const sinon = require("sinon");
 
 // React
 const { createFactory } = require("devtools/client/shared/vendor/react");
+const Provider = createFactory(require("react-redux").Provider);
+const { setupStore } = require("devtools/client/webconsole/new-console-output/test/helpers");
 
 // Components under test.
 const ConsoleApiCall = createFactory(require("devtools/client/webconsole/new-console-output/components/message-types/console-api-call"));
+const {
+  MESSAGE_OPEN,
+  MESSAGE_CLOSE,
+} = require("devtools/client/webconsole/new-console-output/constants");
+const { INDENT_WIDTH } = require("devtools/client/webconsole/new-console-output/components/message-indent");
 
 // Test fakes.
 const { stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 const serviceContainer = require("devtools/client/webconsole/new-console-output/test/fixtures/serviceContainer");
 
 const tempfilePath = "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js";
 
 describe("ConsoleAPICall component:", () => {
@@ -40,16 +48,28 @@ describe("ConsoleAPICall component:", ()
         .set("repeat", 107);
       const wrapper = render(ConsoleApiCall({ message, serviceContainer }));
 
       expect(wrapper.find(".message-repeats").text()).toBe("107");
       expect(wrapper.find(".message-repeats").prop("title")).toBe("107 repeats");
 
       expect(wrapper.find("span > span.message-flex-body > span.message-body.devtools-monospace + span.message-repeats").length).toBe(1);
     });
+
+    it("has the expected indent", () => {
+      const message = stubPreparedMessages.get("console.log('foobar', 'test')");
+
+      const indent = 10;
+      let wrapper = render(ConsoleApiCall({ message, serviceContainer, indent }));
+      expect(wrapper.find(".indent").prop("style").width)
+        .toBe(`${indent * INDENT_WIDTH}px`);
+
+      wrapper = render(ConsoleApiCall({ message, serviceContainer}));
+      expect(wrapper.find(".indent").prop("style").width).toBe(`0`);
+    });
   });
 
   describe("console.count", () => {
     it("renders", () => {
       const message = stubPreparedMessages.get("console.count('bar')");
       const wrapper = render(ConsoleApiCall({ message, serviceContainer }));
 
       expect(wrapper.find(".message-body").text()).toBe("bar: 1");
@@ -100,9 +120,74 @@ describe("ConsoleAPICall component:", ()
 
       expect(frameLinks.eq(1).find(".frame-link-function-display-name").text()).toBe("foo");
       expect(frameLinks.eq(1).find(".frame-link-filename").text()).toBe(filepath);
 
       expect(frameLinks.eq(2).find(".frame-link-function-display-name").text()).toBe("triggerPacket");
       expect(frameLinks.eq(2).find(".frame-link-filename").text()).toBe(filepath);
     });
   });
+
+  describe("console.group", () => {
+    it("renders", () => {
+      const message = stubPreparedMessages.get("console.group('bar')");
+      const wrapper = render(ConsoleApiCall({ message, serviceContainer, open: true }));
+
+      expect(wrapper.find(".message-body").text()).toBe(message.messageText);
+      expect(wrapper.find(".theme-twisty.open").length).toBe(1);
+    });
+
+    it("toggle the group when the collapse button is clicked", () => {
+      const store = setupStore([]);
+      store.dispatch = sinon.spy();
+      const message = stubPreparedMessages.get("console.group('bar')");
+
+      let wrapper = mount(Provider({store},
+        ConsoleApiCall({
+          message,
+          open: true,
+          dispatch: store.dispatch,
+          serviceContainer,
+        })
+      ));
+      wrapper.find(".theme-twisty.open").simulate("click");
+      let call = store.dispatch.getCall(0);
+      expect(call.args[0]).toEqual({
+        id: message.id,
+        type: MESSAGE_CLOSE
+      });
+
+      wrapper = mount(Provider({store},
+        ConsoleApiCall({
+          message,
+          open: false,
+          dispatch: store.dispatch,
+          serviceContainer,
+        })
+      ));
+      wrapper.find(".theme-twisty").simulate("click");
+      call = store.dispatch.getCall(1);
+      expect(call.args[0]).toEqual({
+        id: message.id,
+        type: MESSAGE_OPEN
+      });
+    });
+  });
+
+  describe("console.groupEnd", () => {
+    it("does not show anything", () => {
+      const message = stubPreparedMessages.get("console.groupEnd('bar')");
+      const wrapper = render(ConsoleApiCall({ message, serviceContainer }));
+
+      expect(wrapper.find(".message-body").text()).toBe("");
+    });
+  });
+
+  describe("console.groupCollapsed", () => {
+    it("renders", () => {
+      const message = stubPreparedMessages.get("console.groupCollapsed('foo')");
+      const wrapper = render(ConsoleApiCall({ message, serviceContainer, open: false}));
+
+      expect(wrapper.find(".message-body").text()).toBe(message.messageText);
+      expect(wrapper.find(".theme-twisty:not(.open)").length).toBe(1);
+    });
+  });
 });
--- a/devtools/client/webconsole/new-console-output/test/components/evaluation-result.test.js
+++ b/devtools/client/webconsole/new-console-output/test/components/evaluation-result.test.js
@@ -6,16 +6,17 @@
 const expect = require("expect");
 const { render } = require("enzyme");
 
 // React
 const { createFactory } = require("devtools/client/shared/vendor/react");
 
 // Components under test.
 const EvaluationResult = createFactory(require("devtools/client/webconsole/new-console-output/components/message-types/evaluation-result"));
+const { INDENT_WIDTH } = require("devtools/client/webconsole/new-console-output/components/message-indent");
 
 // Test fakes.
 const { stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 
 describe("EvaluationResult component:", () => {
   it("renders a grip result", () => {
     const message = stubPreparedMessages.get("new Date(0)");
     const wrapper = render(EvaluationResult({ message }));
@@ -29,9 +30,21 @@ describe("EvaluationResult component:", 
     const message = stubPreparedMessages.get("asdf()");
     const wrapper = render(EvaluationResult({ message }));
 
     expect(wrapper.find(".message-body").text())
       .toBe("ReferenceError: asdf is not defined");
 
     expect(wrapper.find(".message.error").length).toBe(1);
   });
+
+  it("has the expected indent", () => {
+    const message = stubPreparedMessages.get("new Date(0)");
+
+    const indent = 10;
+    let wrapper = render(EvaluationResult({ message, indent}));
+    expect(wrapper.find(".indent").prop("style").width)
+        .toBe(`${indent * INDENT_WIDTH}px`);
+
+    wrapper = render(EvaluationResult({ message}));
+    expect(wrapper.find(".indent").prop("style").width).toBe(`0`);
+  });
 });
--- a/devtools/client/webconsole/new-console-output/test/components/network-event-message.test.js
+++ b/devtools/client/webconsole/new-console-output/test/components/network-event-message.test.js
@@ -6,16 +6,17 @@
 const expect = require("expect");
 const { render } = require("enzyme");
 
 // React
 const { createFactory } = require("devtools/client/shared/vendor/react");
 
 // Components under test.
 const NetworkEventMessage = createFactory(require("devtools/client/webconsole/new-console-output/components/message-types/network-event-message"));
+const { INDENT_WIDTH } = require("devtools/client/webconsole/new-console-output/components/message-indent");
 
 // Test fakes.
 const { stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 const serviceContainer = require("devtools/client/webconsole/new-console-output/test/fixtures/serviceContainer");
 
 const EXPECTED_URL = "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/inexistent.html";
 
 describe("NetworkEventMessage component:", () => {
@@ -25,16 +26,28 @@ describe("NetworkEventMessage component:
       const wrapper = render(NetworkEventMessage({ message, serviceContainer }));
 
       expect(wrapper.find(".message-body .method").text()).toBe("GET");
       expect(wrapper.find(".message-body .xhr").length).toBe(0);
       expect(wrapper.find(".message-body .url").length).toBe(1);
       expect(wrapper.find(".message-body .url").text()).toBe(EXPECTED_URL);
       expect(wrapper.find("div.message.cm-s-mozilla span.message-body.devtools-monospace").length).toBe(1);
     });
+
+    it("has the expected indent", () => {
+      const message = stubPreparedMessages.get("GET request");
+
+      const indent = 10;
+      let wrapper = render(NetworkEventMessage({ message, serviceContainer, indent}));
+      expect(wrapper.find(".indent").prop("style").width)
+        .toBe(`${indent * INDENT_WIDTH}px`);
+
+      wrapper = render(NetworkEventMessage({ message, serviceContainer }));
+      expect(wrapper.find(".indent").prop("style").width).toBe(`0`);
+    });
   });
 
   describe("XHR GET request", () => {
     it("renders as expected", () => {
       const message = stubPreparedMessages.get("XHR GET request");
       const wrapper = render(NetworkEventMessage({ message, serviceContainer }));
 
       expect(wrapper.find(".message-body .method").text()).toBe("GET");
--- a/devtools/client/webconsole/new-console-output/test/components/page-error.test.js
+++ b/devtools/client/webconsole/new-console-output/test/components/page-error.test.js
@@ -3,16 +3,17 @@
 "use strict";
 
 // Test utils.
 const expect = require("expect");
 const { render } = require("enzyme");
 
 // Components under test.
 const PageError = require("devtools/client/webconsole/new-console-output/components/message-types/page-error");
+const { INDENT_WIDTH } = require("devtools/client/webconsole/new-console-output/components/message-indent");
 
 // Test fakes.
 const { stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 const serviceContainer = require("devtools/client/webconsole/new-console-output/test/fixtures/serviceContainer");
 
 describe("PageError component:", () => {
   it("renders", () => {
     const message = stubPreparedMessages.get("ReferenceError: asdf is not defined");
@@ -35,9 +36,20 @@ describe("PageError component:", () => {
   it("has a stacktrace which can be openned", () => {
     const message = stubPreparedMessages.get("ReferenceError: asdf is not defined");
     const wrapper = render(PageError({ message, serviceContainer, open: true }));
 
     // There should be three stacktrace items.
     const frameLinks = wrapper.find(`.stack-trace span.frame-link`);
     expect(frameLinks.length).toBe(3);
   });
+
+  it("has the expected indent", () => {
+    const message = stubPreparedMessages.get("ReferenceError: asdf is not defined");
+    const indent = 10;
+    let wrapper = render(PageError({ message, serviceContainer, indent}));
+    expect(wrapper.find(".indent").prop("style").width)
+        .toBe(`${indent * INDENT_WIDTH}px`);
+
+    wrapper = render(PageError({ message, serviceContainer}));
+    expect(wrapper.find(".indent").prop("style").width).toBe(`0`);
+  });
 });
--- a/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/stub-snippets.js
+++ b/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/stub-snippets.js
@@ -52,18 +52,39 @@ consoleApi.set("console.table('bar')", {
 console.table('bar');
 `});
 
 consoleApi.set("console.table(['a', 'b', 'c'])", {
   keys: ["console.table(['a', 'b', 'c'])"],
   code: `
 console.table(['a', 'b', 'c']);
 `});
+
+consoleApi.set("console.group('bar')", {
+  keys: ["console.group('bar')", "console.groupEnd('bar')"],
+  code: `
+console.group("bar");
+console.groupEnd("bar");
+`});
+
+consoleApi.set("console.groupCollapsed('foo')", {
+  keys: ["console.groupCollapsed('foo')", "console.groupEnd('foo')"],
+  code: `
+console.groupCollapsed("foo");
+console.groupEnd("foo");
+`});
+
+consoleApi.set("console.group()", {
+  keys: ["console.group()", "console.groupEnd()"],
+  code: `
+console.group();
+console.groupEnd();
+`});
+
 // Evaluation Result
-
 const evaluationResultCommands = [
   "new Date(0)",
   "asdf()"
 ];
 
 let evaluationResult = new Map(evaluationResultCommands.map(cmd => [cmd, cmd]));
 
 // Network Event
--- a/devtools/client/webconsole/new-console-output/test/fixtures/stubs/consoleApi.js
+++ b/devtools/client/webconsole/new-console-output/test/fixtures/stubs/consoleApi.js
@@ -20,167 +20,175 @@ stubPreparedMessages.set("console.log('f
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"foobar",
 		"test"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"foobar\",\"test\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27foobar%27%2C%20%27test%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"foobar\",\"test\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27foobar%27%2C%20%27test%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27foobar%27%2C%20%27test%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log(undefined)", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		{
 			"type": "undefined"
 		}
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"undefined\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(undefined)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"undefined\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(undefined)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(undefined)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.warn('danger, will robinson!')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "warn",
 	"level": "warn",
 	"messageText": null,
 	"parameters": [
 		"danger, will robinson!"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"warn\",\"level\":\"warn\",\"messageText\":null,\"parameters\":[\"danger, will robinson!\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.warn(%27danger%2C%20will%20robinson!%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"warn\",\"level\":\"warn\",\"messageText\":null,\"parameters\":[\"danger, will robinson!\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.warn(%27danger%2C%20will%20robinson!%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.warn(%27danger%2C%20will%20robinson!%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log(NaN)", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		{
 			"type": "NaN"
 		}
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"NaN\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(NaN)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"NaN\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(NaN)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(NaN)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log(null)", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		{
 			"type": "null"
 		}
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"null\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(null)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"null\"}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(null)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(null)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log('鼬')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"鼬"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"鼬\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%E9%BC%AC%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"鼬\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%E9%BC%AC%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%E9%BC%AC%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.clear()", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "clear",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"Console was cleared."
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"clear\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"Console was cleared.\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.clear()\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"clear\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"Console was cleared.\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.clear()\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.clear()",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.count('bar')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "debug",
 	"messageText": "bar: 1",
 	"parameters": null,
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"debug\",\"messageText\":\"bar: 1\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.count(%27bar%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"debug\",\"messageText\":\"bar: 1\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.count(%27bar%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.count(%27bar%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.assert(false, {message: 'foobar'})", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "assert",
 	"level": "error",
@@ -205,83 +213,86 @@ stubPreparedMessages.set("console.assert
 					}
 				},
 				"ownPropertiesLength": 1,
 				"safeGetterValues": {}
 			}
 		}
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"assert\",\"level\":\"error\",\"messageText\":null,\"parameters\":[{\"type\":\"object\",\"actor\":\"server1.conn8.child1/obj31\",\"class\":\"Object\",\"extensible\":true,\"frozen\":false,\"sealed\":false,\"ownPropertyLength\":1,\"preview\":{\"kind\":\"Object\",\"ownProperties\":{\"message\":{\"configurable\":true,\"enumerable\":true,\"writable\":true,\"value\":\"foobar\"}},\"ownPropertiesLength\":1,\"safeGetterValues\":{}}}],\"repeatId\":null,\"stacktrace\":[{\"columnNumber\":27,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)\",\"functionName\":\"triggerPacket\",\"language\":2,\"lineNumber\":1}],\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"assert\",\"level\":\"error\",\"messageText\":null,\"parameters\":[{\"type\":\"object\",\"actor\":\"server1.conn8.child1/obj31\",\"class\":\"Object\",\"extensible\":true,\"frozen\":false,\"sealed\":false,\"ownPropertyLength\":1,\"preview\":{\"kind\":\"Object\",\"ownProperties\":{\"message\":{\"configurable\":true,\"enumerable\":true,\"writable\":true,\"value\":\"foobar\"}},\"ownPropertiesLength\":1,\"safeGetterValues\":{}}}],\"repeatId\":null,\"stacktrace\":[{\"columnNumber\":27,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)\",\"functionName\":\"triggerPacket\",\"language\":2,\"lineNumber\":1}],\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": [
 		{
 			"columnNumber": 27,
 			"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)",
 			"functionName": "triggerPacket",
 			"language": 2,
 			"lineNumber": 1
 		}
 	],
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log('hello \nfrom \rthe \"string world!')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"hello \nfrom \rthe \"string world!"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"hello \\nfrom \\rthe \\\"string world!\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27hello%20%5Cnfrom%20%5Crthe%20%5C%22string%20world!%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"hello \\nfrom \\rthe \\\"string world!\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27hello%20%5Cnfrom%20%5Crthe%20%5C%22string%20world!%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27hello%20%5Cnfrom%20%5Crthe%20%5C%22string%20world!%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.log('úṇĩçödê țĕșť')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"úṇĩçödê țĕșť"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"úṇĩçödê țĕșť\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%C3%BA%E1%B9%87%C4%A9%C3%A7%C3%B6d%C3%AA%20%C8%9B%C4%95%C8%99%C5%A5%27)\",\"line\":1,\"column\":27}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"úṇĩçödê țĕșť\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%C3%BA%E1%B9%87%C4%A9%C3%A7%C3%B6d%C3%AA%20%C8%9B%C4%95%C8%99%C5%A5%27)\",\"line\":1,\"column\":27},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.log(%27%C3%BA%E1%B9%87%C4%A9%C3%A7%C3%B6d%C3%AA%20%C8%9B%C4%95%C8%99%C5%A5%27)",
 		"line": 1,
 		"column": 27
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.trace()", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "trace",
 	"level": "log",
 	"messageText": null,
 	"parameters": [],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"trace\",\"level\":\"log\",\"messageText\":null,\"parameters\":[],\"repeatId\":null,\"stacktrace\":[{\"columnNumber\":3,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"testStacktraceFiltering\",\"language\":2,\"lineNumber\":3},{\"columnNumber\":3,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"foo\",\"language\":2,\"lineNumber\":6},{\"columnNumber\":1,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"triggerPacket\",\"language\":2,\"lineNumber\":9}],\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"line\":3,\"column\":3}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"trace\",\"level\":\"log\",\"messageText\":null,\"parameters\":[],\"repeatId\":null,\"stacktrace\":[{\"columnNumber\":3,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"testStacktraceFiltering\",\"language\":2,\"lineNumber\":3},{\"columnNumber\":3,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"foo\",\"language\":2,\"lineNumber\":6},{\"columnNumber\":1,\"filename\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"functionName\":\"triggerPacket\",\"language\":2,\"lineNumber\":9}],\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()\",\"line\":3,\"column\":3},\"groupId\":null}",
 	"stacktrace": [
 		{
 			"columnNumber": 3,
 			"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()",
 			"functionName": "testStacktraceFiltering",
 			"language": 2,
 			"lineNumber": 3
 		},
@@ -299,73 +310,77 @@ stubPreparedMessages.set("console.trace(
 			"language": 2,
 			"lineNumber": 9
 		}
 	],
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()",
 		"line": 3,
 		"column": 3
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.time('bar')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "nullMessage",
 	"level": "log",
 	"messageText": null,
 	"parameters": null,
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"nullMessage\",\"level\":\"log\",\"messageText\":null,\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)\",\"line\":2,\"column\":1}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"nullMessage\",\"level\":\"log\",\"messageText\":null,\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)\",\"line\":2,\"column\":1},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)",
 		"line": 2,
 		"column": 1
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.timeEnd('bar')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "timeEnd",
 	"level": "log",
-	"messageText": "bar: 1.81ms",
+	"messageText": "bar: 1.77ms",
 	"parameters": null,
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"timeEnd\",\"level\":\"log\",\"messageText\":\"bar: 1.81ms\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)\",\"line\":3,\"column\":1}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"timeEnd\",\"level\":\"log\",\"messageText\":\"bar: 1.77ms\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)\",\"line\":3,\"column\":1},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.time(%27bar%27)",
 		"line": 3,
 		"column": 1
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.table('bar')", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "log",
 	"level": "log",
 	"messageText": null,
 	"parameters": [
 		"bar"
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"bar\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%27bar%27)\",\"line\":2,\"column\":1}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"log\",\"level\":\"log\",\"messageText\":null,\"parameters\":[\"bar\"],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%27bar%27)\",\"line\":2,\"column\":1},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%27bar%27)",
 		"line": 2,
 		"column": 1
-	}
+	},
+	"groupId": null
 }));
 
 stubPreparedMessages.set("console.table(['a', 'b', 'c'])", new ConsoleMessage({
 	"id": "1",
 	"allowRepeating": true,
 	"source": "console-api",
 	"type": "table",
 	"level": "log",
@@ -386,23 +401,138 @@ stubPreparedMessages.set("console.table(
 					"a",
 					"b",
 					"c"
 				]
 			}
 		}
 	],
 	"repeat": 1,
-	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"table\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"object\",\"actor\":\"server1.conn14.child1/obj31\",\"class\":\"Array\",\"extensible\":true,\"frozen\":false,\"sealed\":false,\"ownPropertyLength\":4,\"preview\":{\"kind\":\"ArrayLike\",\"length\":3,\"items\":[\"a\",\"b\",\"c\"]}}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%5B%27a%27%2C%20%27b%27%2C%20%27c%27%5D)\",\"line\":2,\"column\":1}}",
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"table\",\"level\":\"log\",\"messageText\":null,\"parameters\":[{\"type\":\"object\",\"actor\":\"server1.conn14.child1/obj31\",\"class\":\"Array\",\"extensible\":true,\"frozen\":false,\"sealed\":false,\"ownPropertyLength\":4,\"preview\":{\"kind\":\"ArrayLike\",\"length\":3,\"items\":[\"a\",\"b\",\"c\"]}}],\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%5B%27a%27%2C%20%27b%27%2C%20%27c%27%5D)\",\"line\":2,\"column\":1},\"groupId\":null}",
 	"stacktrace": null,
 	"frame": {
 		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.table(%5B%27a%27%2C%20%27b%27%2C%20%27c%27%5D)",
 		"line": 2,
 		"column": 1
-	}
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.group('bar')", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "startGroup",
+	"level": "log",
+	"messageText": "bar",
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"startGroup\",\"level\":\"log\",\"messageText\":\"bar\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)\",\"line\":2,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)",
+		"line": 2,
+		"column": 1
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.groupEnd('bar')", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "endGroup",
+	"level": "log",
+	"messageText": null,
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"endGroup\",\"level\":\"log\",\"messageText\":null,\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)\",\"line\":3,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)",
+		"line": 3,
+		"column": 1
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.groupCollapsed('foo')", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "startGroupCollapsed",
+	"level": "log",
+	"messageText": "foo",
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"startGroupCollapsed\",\"level\":\"log\",\"messageText\":\"foo\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)\",\"line\":2,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)",
+		"line": 2,
+		"column": 1
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.groupEnd('foo')", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "endGroup",
+	"level": "log",
+	"messageText": null,
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"endGroup\",\"level\":\"log\",\"messageText\":null,\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)\",\"line\":3,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)",
+		"line": 3,
+		"column": 1
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.group()", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "startGroup",
+	"level": "log",
+	"messageText": "<no group label>",
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"startGroup\",\"level\":\"log\",\"messageText\":\"<no group label>\",\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()\",\"line\":2,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()",
+		"line": 2,
+		"column": 1
+	},
+	"groupId": null
+}));
+
+stubPreparedMessages.set("console.groupEnd()", new ConsoleMessage({
+	"id": "1",
+	"allowRepeating": true,
+	"source": "console-api",
+	"type": "endGroup",
+	"level": "log",
+	"messageText": null,
+	"parameters": null,
+	"repeat": 1,
+	"repeatId": "{\"id\":null,\"allowRepeating\":true,\"source\":\"console-api\",\"type\":\"endGroup\",\"level\":\"log\",\"messageText\":null,\"parameters\":null,\"repeatId\":null,\"stacktrace\":null,\"frame\":{\"source\":\"http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()\",\"line\":3,\"column\":1},\"groupId\":null}",
+	"stacktrace": null,
+	"frame": {
+		"source": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()",
+		"line": 3,
+		"column": 1
+	},
+	"groupId": null
 }));
 
 
 stubPackets.set("console.log('foobar', 'test')", {
 	"from": "server1.conn0.child1/consoleActor2",
 	"type": "consoleAPICall",
 	"message": {
 		"arguments": [
@@ -422,17 +552,17 @@ stubPackets.set("console.log('foobar', '
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757913492,
+		"timeStamp": 1475510513097,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.log(undefined)", {
 	"from": "server1.conn1.child1/consoleActor2",
@@ -456,17 +586,17 @@ stubPackets.set("console.log(undefined)"
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757916196,
+		"timeStamp": 1475510515740,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.warn('danger, will robinson!')", {
 	"from": "server1.conn2.child1/consoleActor2",
@@ -488,17 +618,17 @@ stubPackets.set("console.warn('danger, w
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757918499,
+		"timeStamp": 1475510518140,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.log(NaN)", {
 	"from": "server1.conn3.child1/consoleActor2",
@@ -522,17 +652,17 @@ stubPackets.set("console.log(NaN)", {
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757920577,
+		"timeStamp": 1475510520239,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.log(null)", {
 	"from": "server1.conn4.child1/consoleActor2",
@@ -556,17 +686,17 @@ stubPackets.set("console.log(null)", {
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757922439,
+		"timeStamp": 1475510522141,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.log('鼬')", {
 	"from": "server1.conn5.child1/consoleActor2",
@@ -588,17 +718,17 @@ stubPackets.set("console.log('鼬')", {
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757924400,
+		"timeStamp": 1475510524415,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.clear()", {
 	"from": "server1.conn6.child1/consoleActor2",
@@ -617,17 +747,17 @@ stubPackets.set("console.clear()", {
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757926626,
+		"timeStamp": 1475510526448,
 		"timer": null,
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.count('bar')", {
@@ -652,17 +782,17 @@ stubPackets.set("console.count('bar')", 
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757929281,
+		"timeStamp": 1475510528672,
 		"timer": null,
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.assert(false, {message: 'foobar'})", {
@@ -706,17 +836,17 @@ stubPackets.set("console.assert(false, {
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757931800,
+		"timeStamp": 1475510531196,
 		"timer": null,
 		"stacktrace": [
 			{
 				"columnNumber": 27,
 				"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.assert(false%2C%20%7Bmessage%3A%20%27foobar%27%7D)",
 				"functionName": "triggerPacket",
 				"language": 2,
 				"lineNumber": 1
@@ -747,17 +877,17 @@ stubPackets.set("console.log('hello \nfr
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757936217,
+		"timeStamp": 1475510533644,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.log('úṇĩçödê țĕșť')", {
 	"from": "server1.conn10.child1/consoleActor2",
@@ -779,17 +909,17 @@ stubPackets.set("console.log('úṇĩçödê țĕșť')", {
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
 		"styles": [],
-		"timeStamp": 1474757938480,
+		"timeStamp": 1475510535688,
 		"timer": null,
 		"workerType": "none",
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.trace()", {
 	"from": "server1.conn11.child1/consoleActor2",
@@ -808,17 +938,17 @@ stubPackets.set("console.trace()", {
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757940569,
+		"timeStamp": 1475510537832,
 		"timer": null,
 		"stacktrace": [
 			{
 				"columnNumber": 3,
 				"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.trace()",
 				"functionName": "testStacktraceFiltering",
 				"language": 2,
 				"lineNumber": 3
@@ -863,20 +993,20 @@ stubPackets.set("console.time('bar')", {
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757942740,
+		"timeStamp": 1475510540136,
 		"timer": {
 			"name": "bar",
-			"started": 1220.705
+			"started": 1512.2350000000001
 		},
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.timeEnd('bar')", {
@@ -898,19 +1028,19 @@ stubPackets.set("console.timeEnd('bar')"
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757942742,
+		"timeStamp": 1475510540138,
 		"timer": {
-			"duration": 1.8100000000001728,
+			"duration": 1.7749999999998636,
 			"name": "bar"
 		},
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
@@ -933,17 +1063,17 @@ stubPackets.set("console.table('bar')", 
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757944789,
+		"timeStamp": 1475510542241,
 		"timer": null,
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
 stubPackets.set("console.table(['a', 'b', 'c'])", {
@@ -982,17 +1112,205 @@ stubPackets.set("console.table(['a', 'b'
 			"appId": 0,
 			"firstPartyDomain": "",
 			"inIsolatedMozBrowser": false,
 			"privateBrowsingId": 0,
 			"signedPkg": "",
 			"userContextId": 0
 		},
 		"private": false,
-		"timeStamp": 1474757946731,
+		"timeStamp": 1475510544147,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.group('bar')", {
+	"from": "server1.conn15.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [
+			"bar"
+		],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)",
+		"functionName": "triggerPacket",
+		"groupName": "bar",
+		"level": "group",
+		"lineNumber": 2,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510546599,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.groupEnd('bar')", {
+	"from": "server1.conn15.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [
+			"bar"
+		],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group(%27bar%27)",
+		"functionName": "triggerPacket",
+		"groupName": "bar",
+		"level": "groupEnd",
+		"lineNumber": 3,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510546601,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.groupCollapsed('foo')", {
+	"from": "server1.conn16.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [
+			"foo"
+		],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)",
+		"functionName": "triggerPacket",
+		"groupName": "foo",
+		"level": "groupCollapsed",
+		"lineNumber": 2,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510548649,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.groupEnd('foo')", {
+	"from": "server1.conn16.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [
+			"foo"
+		],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.groupCollapsed(%27foo%27)",
+		"functionName": "triggerPacket",
+		"groupName": "foo",
+		"level": "groupEnd",
+		"lineNumber": 3,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510548650,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.group()", {
+	"from": "server1.conn17.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()",
+		"functionName": "triggerPacket",
+		"groupName": "",
+		"level": "group",
+		"lineNumber": 2,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510550811,
+		"timer": null,
+		"workerType": "none",
+		"styles": [],
+		"category": "webdev"
+	}
+});
+
+stubPackets.set("console.groupEnd()", {
+	"from": "server1.conn17.child1/consoleActor2",
+	"type": "consoleAPICall",
+	"message": {
+		"arguments": [],
+		"columnNumber": 1,
+		"counter": null,
+		"filename": "http://example.com/browser/devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/test-tempfile.js?key=console.group()",
+		"functionName": "triggerPacket",
+		"groupName": "",
+		"level": "groupEnd",
+		"lineNumber": 3,
+		"originAttributes": {
+			"addonId": "",
+			"appId": 0,
+			"firstPartyDomain": "",
+			"inIsolatedMozBrowser": false,
+			"privateBrowsingId": 0,
+			"signedPkg": "",
+			"userContextId": 0
+		},
+		"private": false,
+		"timeStamp": 1475510550813,
 		"timer": null,
 		"workerType": "none",
 		"styles": [],
 		"category": "webdev"
 	}
 });
 
 
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
@@ -1,18 +1,19 @@
 [DEFAULT]
 tags = devtools
 subsuite = devtools
 support-files =
   head.js
-  !/devtools/client/framework/test/shared-head.js
-  test-console-table.html
   test-console.html
   test-console-filters.html
+  test-console-group.html
+  test-console-table.html
+  !/devtools/client/framework/test/shared-head.js
 
+[browser_webconsole_console_group.js]
 [browser_webconsole_console_table.js]
 [browser_webconsole_filters.js]
 [browser_webconsole_init.js]
 [browser_webconsole_input_focus.js]
 [browser_webconsole_keyboard_accessibility.js]
 [browser_webconsole_observer_notifications.js]
 [browser_webconsole_vview_close_on_esc_key.js]
-
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_console_group.js
@@ -0,0 +1,70 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check console.group, console.groupCollapsed and console.groupEnd calls
+// behave as expected.
+
+const TEST_URI = "http://example.com/browser/devtools/client/webconsole/new-console-output/test/mochitest/test-console-group.html";
+const { INDENT_WIDTH } = require("devtools/client/webconsole/new-console-output/components/message-indent");
+
+add_task(function* () {
+  let toolbox = yield openNewTabAndToolbox(TEST_URI, "webconsole");
+  let hud = toolbox.getCurrentPanel().hud;
+
+  info("Test a group at root level");
+  let node = yield waitFor(() => findMessage(hud, "group-1"));
+  testClass(node, "startGroup");
+  testIndent(node, 0);
+
+  info("Test a message in a 1 level deep group");
+  node = yield waitFor(() => findMessage(hud, "log-1"));
+  testClass(node, "log");
+  testIndent(node, 1);
+
+  info("Test a group in a 1 level deep group");
+  node = yield waitFor(() => findMessage(hud, "group-2"));
+  testClass(node, "startGroup");
+  testIndent(node, 1);
+
+  info("Test a message in a 2 level deep group");
+  node = yield waitFor(() => findMessage(hud, "log-2"));
+  testClass(node, "log");
+  testIndent(node, 2);
+
+  info("Test a message in a 1 level deep group, after closing a 2 level deep group");
+  node = yield waitFor(() => findMessage(hud, "log-3"));
+  testClass(node, "log");
+  testIndent(node, 1);
+
+  info("Test a message at root level, after closing all the groups");
+  node = yield waitFor(() => findMessage(hud, "log-4"));
+  testClass(node, "log");
+  testIndent(node, 0);
+
+  info("Test a collapsed group at root level");
+  node = yield waitFor(() => findMessage(hud, "group-3"));
+  testClass(node, "startGroupCollapsed");
+  testIndent(node, 0);
+
+  info("Test a message at root level, after closing a collapsed group");
+  node = yield waitFor(() => findMessage(hud, "log-6"));
+  testClass(node, "log");
+  testIndent(node, 0);
+
+  let nodes = hud.ui.experimentalOutputNode.querySelectorAll(".message");
+  is(nodes.length, 8, "expected number of messages are displayed");
+});
+
+function testClass(node, className) {
+  ok(node.classList.contains(className, "message has the expected class"));
+}
+
+function testIndent(node, indent) {
+  indent = `${indent * INDENT_WIDTH}px`;
+  is(node.querySelector(".indent").style.width, indent,
+    "message has the expected level of indentation");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/test-console-group.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>Webconsole console.group test page</title>
+  </head>
+  <body>
+    <p>console.group() & console.groupCollapsed() test page</p>
+    <script>
+    "use strict";
+
+    console.group("group-1");
+    console.log("log-1");
+    console.group("group-2");
+    console.log("log-2");
+    console.groupEnd("group-2");
+    console.log("log-3");
+    console.groupEnd("group-1");
+    console.log("log-4");
+    console.groupCollapsed("group-3");
+    console.log("log-5");
+    console.groupEnd("group-3");
+    console.log("log-6");
+    </script>
+  </body>
+</html>
--- a/devtools/client/webconsole/new-console-output/test/store/filters.test.js
+++ b/devtools/client/webconsole/new-console-output/test/store/filters.test.js
@@ -14,18 +14,18 @@ const { setupStore } = require("devtools
 const { MESSAGE_LEVEL } = require("devtools/client/webconsole/new-console-output/constants");
 const { stubPackets } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 const { stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 
 describe("Filtering", () => {
   let store;
   let numMessages;
   // Number of messages in prepareBaseStore which are not filtered out, i.e. Evaluation
-  // Results and console commands .
-  const numUnfilterableMessages = 2;
+  // Results, console commands and console.groups .
+  const numUnfilterableMessages = 3;
 
   beforeEach(() => {
     store = prepareBaseStore();
     store.dispatch(actions.filtersClear());
     numMessages = getAllMessages(store.getState()).size;
   });
 
   describe("Level filter", () => {
@@ -199,16 +199,17 @@ function prepareBaseStore() {
     "console.log('foobar', 'test')",
     "console.warn('danger, will robinson!')",
     "console.log(undefined)",
     "console.count('bar')",
     "console.log('鼬')",
     // Evaluation Result - never filtered
     "new Date(0)",
     // PageError
-    "ReferenceError: asdf is not defined"
+    "ReferenceError: asdf is not defined",
+    "console.group('bar')"
   ]);
 
   // Console Command - never filtered
   store.dispatch(messageAdd(new ConsoleCommand({ messageText: `console.warn("x")` })));
 
   return store;
 }
--- a/devtools/client/webconsole/new-console-output/test/store/messages.test.js
+++ b/devtools/client/webconsole/new-console-output/test/store/messages.test.js
@@ -1,15 +1,17 @@
 /* Any copyright is dedicated to the Public Domain.
    http://creativecommons.org/publicdomain/zero/1.0/ */
 "use strict";
 
 const {
   getAllMessages,
   getAllMessagesUiById,
+  getAllGroupsById,
+  getCurrentGroup,
 } = require("devtools/client/webconsole/new-console-output/selectors/messages");
 const {
   setupActions,
   setupStore
 } = require("devtools/client/webconsole/new-console-output/test/helpers");
 const { stubPackets, stubPreparedMessages } = require("devtools/client/webconsole/new-console-output/test/fixtures/stubs/index");
 const {
   MESSAGE_TYPE,
@@ -126,16 +128,85 @@ describe("Message reducer:", () => {
 
       const packet = stubPackets.get("console.table('bar')");
       dispatch(actions.messageAdd(packet));
 
       const messages = getAllMessages(getState());
       const tableMessage = messages.last();
       expect(tableMessage.level).toEqual(MESSAGE_TYPE.LOG);
     });
+
+    it("adds console.group messages to the store", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const message = stubPackets.get("console.group('bar')");
+      dispatch(actions.messageAdd(message));
+
+      const messages = getAllMessages(getState());
+      expect(messages.size).toBe(1);
+    });
+
+    it("sets groupId property as expected", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      dispatch(actions.messageAdd(
+        stubPackets.get("console.group('bar')")));
+
+      const packet = stubPackets.get("console.log('foobar', 'test')");
+      dispatch(actions.messageAdd(packet));
+
+      const messages = getAllMessages(getState());
+      expect(messages.size).toBe(2);
+      expect(messages.last().groupId).toBe(messages.first().id);
+    });
+
+    it("does not display console.groupEnd messages to the store", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const message = stubPackets.get("console.groupEnd('bar')");
+      dispatch(actions.messageAdd(message));
+
+      const messages = getAllMessages(getState());
+      expect(messages.size).toBe(0);
+    });
+
+    it("filters out message added after a console.groupCollapsed message", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const message = stubPackets.get("console.groupCollapsed('foo')");
+      dispatch(actions.messageAdd(message));
+
+      dispatch(actions.messageAdd(
+        stubPackets.get("console.log('foobar', 'test')")));
+
+      const messages = getAllMessages(getState());
+      expect(messages.size).toBe(1);
+    });
+
+    it("shows the group of the first displayed message when messages are pruned", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const logLimit = 1000;
+
+      const groupMessage = stubPreparedMessages.get("console.group('bar')");
+      dispatch(actions.messageAdd(
+        stubPackets.get("console.group('bar')")));
+
+      const packet = stubPackets.get("console.log(undefined)");
+      for (let i = 1; i <= logLimit + 1; i++) {
+        packet.message.arguments = [`message num ${i}`];
+        dispatch(actions.messageAdd(packet));
+      }
+
+      const messages = getAllMessages(getState());
+      expect(messages.count()).toBe(logLimit);
+      expect(messages.first().messageText).toBe(groupMessage.messageText);
+      expect(messages.get(1).parameters[0]).toBe(`message num 3`);
+      expect(messages.last().parameters[0]).toBe(`message num ${logLimit + 1}`);
+    });
   });
 
   describe("messagesUiById", () => {
     it("opens console.trace messages when they are added", () => {
       const { dispatch, getState } = setupStore([]);
 
       const message = stubPackets.get("console.trace()");
       dispatch(actions.messageAdd(message));
@@ -155,10 +226,117 @@ describe("Message reducer:", () => {
       const traceMessage = stubPackets.get("console.trace()");
       dispatch(actions.messageAdd(traceMessage));
 
       dispatch(actions.messagesClear());
 
       const messagesUi = getAllMessagesUiById(getState());
       expect(messagesUi.size).toBe(0);
     });
+
+    it("opens console.group messages when they are added", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const message = stubPackets.get("console.group('bar')");
+      dispatch(actions.messageAdd(message));
+
+      const messages = getAllMessages(getState());
+      const messagesUi = getAllMessagesUiById(getState());
+      expect(messagesUi.size).toBe(1);
+      expect(messagesUi.first()).toBe(messages.first().id);
+    });
+
+    it("does not open console.groupCollapsed messages when they are added", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const message = stubPackets.get("console.groupCollapsed('foo')");
+      dispatch(actions.messageAdd(message));
+
+      const messagesUi = getAllMessagesUiById(getState());
+      expect(messagesUi.size).toBe(0);
+    });
+  });
+
+  describe("currentGroup", () => {
+    it("sets the currentGroup when console.group message is added", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const packet = stubPackets.get("console.group('bar')");
+      dispatch(actions.messageAdd(packet));
+
+      const messages = getAllMessages(getState());
+      const currentGroup = getCurrentGroup(getState());
+      expect(currentGroup).toBe(messages.first().id);
+    });
+
+    it("sets currentGroup to expected value when console.groupEnd is added", () => {
+      const { dispatch, getState } = setupStore([
+        "console.group('bar')",
+        "console.groupCollapsed('foo')"
+      ]);
+
+      let messages = getAllMessages(getState());
+      let currentGroup = getCurrentGroup(getState());
+      expect(currentGroup).toBe(messages.last().id);
+
+      const endFooPacket = stubPackets.get("console.groupEnd('foo')");
+      dispatch(actions.messageAdd(endFooPacket));
+      messages = getAllMessages(getState());
+      currentGroup = getCurrentGroup(getState());
+      expect(currentGroup).toBe(messages.first().id);
+
+      const endBarPacket = stubPackets.get("console.groupEnd('foo')");
+      dispatch(actions.messageAdd(endBarPacket));
+      messages = getAllMessages(getState());
+      currentGroup = getCurrentGroup(getState());
+      expect(currentGroup).toBe(null);
+    });
+
+    it("resets the currentGroup to null in response to MESSAGES_CLEAR action", () => {
+      const { dispatch, getState } = setupStore([
+        "console.group('bar')"
+      ]);
+
+      dispatch(actions.messagesClear());
+
+      const currentGroup = getCurrentGroup(getState());
+      expect(currentGroup).toBe(null);
+    });
+  });
+
+  describe("groupsById", () => {
+    it("adds the group with expected array when console.group message is added", () => {
+      const { dispatch, getState } = setupStore([]);
+
+      const barPacket = stubPackets.get("console.group('bar')");
+      dispatch(actions.messageAdd(barPacket));
+
+      let messages = getAllMessages(getState());
+      let groupsById = getAllGroupsById(getState());
+      expect(groupsById.size).toBe(1);
+      expect(groupsById.has(messages.first().id)).toBe(true);
+      expect(groupsById.get(messages.first().id)).toEqual([]);
+
+      const fooPacket = stubPackets.get("console.groupCollapsed('foo')");
+      dispatch(actions.messageAdd(fooPacket));
+      messages = getAllMessages(getState());
+      groupsById = getAllGroupsById(getState());
+      expect(groupsById.size).toBe(2);
+      expect(groupsById.has(messages.last().id)).toBe(true);
+      expect(groupsById.get(messages.last().id)).toEqual([messages.first().id]);
+    });
+
+    it("resets groupsById in response to MESSAGES_CLEAR action", () => {
+      const { dispatch, getState } = setupStore([
+        "console.group('bar')",
+        "console.groupCollapsed('foo')",
+      ]);
+
+      let groupsById = getAllGroupsById(getState());
+      expect(groupsById.size).toBe(2);
+
+      dispatch(actions.messagesClear());
+
+      groupsById = getAllGroupsById(getState());
+      expect(groupsById.size).toBe(0);
+    });
   });
 });
--- a/devtools/client/webconsole/new-console-output/types.js
+++ b/devtools/client/webconsole/new-console-output/types.js
@@ -15,34 +15,37 @@ const {
 
 exports.ConsoleCommand = Immutable.Record({
   id: null,
   allowRepeating: false,
   messageText: null,
   source: MESSAGE_SOURCE.JAVASCRIPT,
   type: MESSAGE_TYPE.COMMAND,
   level: MESSAGE_LEVEL.LOG,
+  groupId: null,
 });
 
 exports.ConsoleMessage = Immutable.Record({
   id: null,
   allowRepeating: true,
   source: null,
   type: null,
   level: null,
   messageText: null,
   parameters: null,
   repeat: 1,
   repeatId: null,
   stacktrace: null,
   frame: null,
+  groupId: null,
 });
 
 exports.NetworkEventMessage = Immutable.Record({
   id: null,
   actor: null,
   level: MESSAGE_LEVEL.LOG,
   isXHR: false,
   request: null,
   response: null,
   source: MESSAGE_SOURCE.NETWORK,
   type: MESSAGE_TYPE.LOG,
+  groupId: null,
 });
--- a/devtools/client/webconsole/new-console-output/utils/messages.js
+++ b/devtools/client/webconsole/new-console-output/utils/messages.js
@@ -89,16 +89,30 @@ function transformPacket(packet) {
             parameters.length === 0 ||
             !supportedClasses.includes(parameters[0].class)
           ) {
             // If the class of the first parameter is not supported,
             // we handle the call as a simple console.log
             type = "log";
           }
           break;
+        case "group":
+          type = MESSAGE_TYPE.START_GROUP;
+          parameters = null;
+          messageText = message.groupName || l10n.getStr("noGroupLabel");
+          break;
+        case "groupCollapsed":
+          type = MESSAGE_TYPE.START_GROUP_COLLAPSED;
+          parameters = null;
+          messageText = message.groupName || l10n.getStr("noGroupLabel");
+          break;
+        case "groupEnd":
+          type = MESSAGE_TYPE.END_GROUP;
+          parameters = null;
+          break;
       }
 
       const frame = message.filename ? {
         source: message.filename,
         line: message.lineNumber,
         column: message.columnNumber,
       } : null;
 
@@ -239,13 +253,21 @@ function getLevelFromType(type) {
     time: levels.LEVEL_LOG,
     timeEnd: levels.LEVEL_LOG,
     count: levels.LEVEL_DEBUG,
   };
 
   return levelMap[type] || MESSAGE_TYPE.LOG;
 }
 
+function isGroupType(type) {
+  return [
+    MESSAGE_TYPE.START_GROUP,
+    MESSAGE_TYPE.START_GROUP_COLLAPSED
+  ].includes(type);
+}
+
 exports.prepareMessage = prepareMessage;
 // Export for use in testing.
 exports.getRepeatId = getRepeatId;
 
 exports.l10n = l10n;
+exports.isGroupType = isGroupType;
--- a/devtools/client/webconsole/test/browser_webconsole_bug_599725_response_headers.js
+++ b/devtools/client/webconsole/test/browser_webconsole_bug_599725_response_headers.js
@@ -4,69 +4,61 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const INIT_URI = "data:text/plain;charset=utf8,hello world";
 const TEST_URI = "http://example.com/browser/devtools/client/webconsole/" +
                  "test/test-bug-599725-response-headers.sjs";
 
-var loads = 0;
-function performTest(request, console) {
+function performTest(request, hud) {
   let deferred = promise.defer();
 
   let headers = null;
 
   function readHeader(name) {
     for (let header of headers) {
       if (header.name == name) {
         return header.value;
       }
     }
     return null;
   }
 
-  console.webConsoleClient.getResponseHeaders(request.actor,
+  hud.ui.proxy.webConsoleClient.getResponseHeaders(request.actor,
     function (response) {
       headers = response.headers;
       ok(headers, "we have the response headers for reload");
 
       let contentType = readHeader("Content-Type");
       let contentLength = readHeader("Content-Length");
 
       ok(!contentType, "we do not have the Content-Type header");
       isnot(contentLength, 60, "Content-Length != 60");
 
       executeSoon(deferred.resolve);
     });
 
-  HUDService.lastFinishedRequest.callback = null;
-
   return deferred.promise;
 }
 
-function waitForRequest() {
-  let deferred = promise.defer();
-  HUDService.lastFinishedRequest.callback = (req, console) => {
-    loads++;
-    ok(req, "page load was logged");
-    if (loads != 2) {
-      return;
-    }
-    performTest(req, console).then(deferred.resolve);
-  };
-  return deferred.promise;
-}
+let waitForRequest = Task.async(function*(hud) {
+  let request = yield waitForFinishedRequest(req=> {
+    return req.response.status === "304";
+  });
+
+  yield performTest(request, hud);
+});
 
 add_task(function* () {
   let { browser } = yield loadTab(INIT_URI);
 
-  yield openConsole();
+  let hud = yield openConsole();
 
-  let gotLastRequest = waitForRequest();
+  let gotLastRequest = waitForRequest(hud);
 
   let loaded = loadBrowser(browser);
   BrowserTestUtils.loadURI(browser, TEST_URI);
   yield loaded;
 
   let reloaded = loadBrowser(browser);
   ContentTask.spawn(browser, null, "() => content.location.reload()");
   yield reloaded;