Bug 1307873 - Add support for console group. r=linclark
authorNicolas Chevobbe <chevobbe.nicolas@gmail.com>
Fri, 07 Oct 2016 07:17:00 -0400
changeset 316930 4b037d091109
parent 316929 fec388445e7c
child 316931 a9f9f5611272
push id30789
push userryanvm@gmail.com
push dateSat, 08 Oct 2016 02:56:21 +0000
treeherdermozilla-central@efa3b654aa77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerslinclark
bugs1307873
milestone52.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1307873 - Add support for console group. r=linclark
devtools/client/locales/en-US/webconsole.properties
devtools/client/themes/webconsole.css
devtools/client/webconsole/new-console-output/actions/messages.js
devtools/client/webconsole/new-console-output/components/collapse-button.js
devtools/client/webconsole/new-console-output/components/console-output.js
devtools/client/webconsole/new-console-output/components/message-container.js
devtools/client/webconsole/new-console-output/components/message-indent.js
devtools/client/webconsole/new-console-output/components/message-types/console-api-call.js
devtools/client/webconsole/new-console-output/components/message-types/console-command.js
devtools/client/webconsole/new-console-output/components/message-types/evaluation-result.js
devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
devtools/client/webconsole/new-console-output/components/message-types/page-error.js
devtools/client/webconsole/new-console-output/components/message.js
devtools/client/webconsole/new-console-output/components/moz.build
devtools/client/webconsole/new-console-output/reducers/messages.js
devtools/client/webconsole/new-console-output/selectors/messages.js
devtools/client/webconsole/new-console-output/test/components/console-api-call.test.js
devtools/client/webconsole/new-console-output/test/components/evaluation-result.test.js
devtools/client/webconsole/new-console-output/test/components/network-event-message.test.js
devtools/client/webconsole/new-console-output/test/components/page-error.test.js
devtools/client/webconsole/new-console-output/test/fixtures/stub-generators/stub-snippets.js
devtools/client/webconsole/new-console-output/test/fixtures/stubs/consoleApi.js
devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_console_group.js
devtools/client/webconsole/new-console-output/test/mochitest/test-console-group.html
devtools/client/webconsole/new-console-output/test/store/filters.test.js
devtools/client/webconsole/new-console-output/test/store/messages.test.js
devtools/client/webconsole/new-console-output/types.js
devtools/client/webconsole/new-console-output/utils/messages.js
--- 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;