Bug 1524276 - Group content blocking messages. r=bgrins,Honza.
☠☠ backed out by e365c225e26a ☠ ☠
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Fri, 29 Mar 2019 08:04:24 +0000
changeset 466719 47363a80ef6d1e7a43a2325d32fe024c205cd001
parent 466718 1fee0c35777292ad11916d48c2a6d9cad17ee5e9
child 466720 20d5cdaa08e33da406709986f812c23404f33a96
push id35780
push useropoprus@mozilla.com
push dateFri, 29 Mar 2019 21:53:01 +0000
treeherdermozilla-central@414f37afbe07 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins, Honza
bugs1524276
milestone68.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 1524276 - Group content blocking messages. r=bgrins,Honza. If there's at least 2 content blocking messages displayed for a given page navigation, we display a warning group containing the messages, collapsed by default. This means we need to move or insert those warning messages at the right position in visibleMessages, either when they're added, or when we expand a group. Two mochitest are added to make sure this works as expected, one for generic warningGroup features (expanding, group per navigation session, …), and another one specifically for content blocking warning group, where we check that every type of content blocking message can be grouped. The grouping won't occur unless the groupWarnings preferences is on. Differential Revision: https://phabricator.services.mozilla.com/D23552
devtools/client/locales/en-US/webconsole.properties
devtools/client/webconsole/components/ConsoleOutput.js
devtools/client/webconsole/reducers/messages.js
devtools/client/webconsole/selectors/messages.js
devtools/client/webconsole/test/components/warning-group.test.js
devtools/client/webconsole/test/fixtures/L10n.js
devtools/client/webconsole/test/mochitest/browser.ini
devtools/client/webconsole/test/mochitest/browser_webconsole_trackingprotection_errors.js
devtools/client/webconsole/test/mochitest/browser_webconsole_warning_group_content_blocking.js
devtools/client/webconsole/test/mochitest/browser_webconsole_warning_groups.js
devtools/client/webconsole/test/mochitest/head.js
devtools/client/webconsole/test/mochitest/test-warning-groups.html
devtools/client/webconsole/utils/messages.js
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -339,8 +339,13 @@ webconsole.reverseSearch.result.nextButt
 # would return "Invoke getter y to retrieve the property list?".
 # Parameters: %S is the name of the getter.
 webconsole.confirmDialog.getter.label=Invoke getter %S to retrieve the property list?
 
 # LOCALIZATION NOTE (webconsole.confirmDialog.getter.invokeButtonLabel)
 # Label used for the confirm button in the "invoke getter" dialog that appears in the
 # console when a user tries to autocomplete a property with a getter.
 webconsole.confirmDialog.getter.invokeButtonLabel=Invoke
+
+# LOCALIZATION NOTE (webconsole.group.contentBlocked)
+# Label used as the group header in the console output when content blocking is enabled
+# and that we have several warning messages about resources being blocked.
+webconsole.group.contentBlocked=Content blocked messages
--- a/devtools/client/webconsole/components/ConsoleOutput.js
+++ b/devtools/client/webconsole/components/ConsoleOutput.js
@@ -11,16 +11,18 @@ const {initialize} = require("devtools/c
 const {
   getAllMessagesById,
   getAllMessagesUiById,
   getAllMessagesTableDataById,
   getAllNetworkMessagesUpdateById,
   getVisibleMessages,
   getPausedExecutionPoint,
   getAllRepeatById,
+  getAllWarningGroupsById,
+  isMessageInWarningGroup,
 } = require("devtools/client/webconsole/selectors/messages");
 
 loader.lazyRequireGetter(this, "PropTypes", "devtools/client/shared/vendor/react-prop-types");
 loader.lazyRequireGetter(this, "sortBy", "devtools/client/shared/vendor/lodash", true);
 loader.lazyRequireGetter(this, "MessageContainer", "devtools/client/webconsole/components/MessageContainer", true);
 
 const {
   MESSAGE_TYPE,
@@ -53,16 +55,18 @@ class ConsoleOutput extends Component {
         attachRefToWebConsoleUI: PropTypes.func.isRequired,
         openContextMenu: PropTypes.func.isRequired,
         sourceMapService: PropTypes.object,
       }),
       dispatch: PropTypes.func.isRequired,
       timestampsVisible: PropTypes.bool,
       messagesTableData: PropTypes.object.isRequired,
       messagesRepeat: PropTypes.object.isRequired,
+      warningGroups: PropTypes.object.isRequired,
+      isInWarningGroup: PropTypes.isRequired,
       networkMessagesUpdate: PropTypes.object.isRequired,
       visibleMessages: PropTypes.array.isRequired,
       networkMessageActiveTabId: PropTypes.string.isRequired,
       onFirstMeaningfulPaint: PropTypes.func.isRequired,
       pausedExecutionPoint: PropTypes.any,
     };
   }
 
@@ -166,16 +170,18 @@ class ConsoleOutput extends Component {
   render() {
     let {
       dispatch,
       visibleMessages,
       messages,
       messagesUi,
       messagesTableData,
       messagesRepeat,
+      warningGroups,
+      isInWarningGroup,
       networkMessagesUpdate,
       networkMessageActiveTabId,
       serviceContainer,
       timestampsVisible,
       initialized,
       pausedExecutionPoint,
     } = this.props;
 
@@ -195,16 +201,20 @@ class ConsoleOutput extends Component {
         dispatch,
         key: messageId,
         messageId,
         serviceContainer,
         open: messagesUi.includes(messageId),
         tableData: messagesTableData.get(messageId),
         timestampsVisible,
         repeat: messagesRepeat[messageId],
+        badge: warningGroups.has(messageId) ? warningGroups.get(messageId).length : null,
+        inWarningGroup: isInWarningGroup
+          ? isInWarningGroup(messages.get(messageId))
+          : false,
         networkMessageUpdate: networkMessagesUpdate[messageId],
         networkMessageActiveTabId,
         pausedExecutionPoint,
         getMessage: () => messages.get(messageId),
         isPaused: !!pausedMessage && pausedMessage.id == messageId,
         maybeScrollToBottom: this.maybeScrollToBottom,
       }));
 
@@ -239,15 +249,19 @@ function mapStateToProps(state, props) {
   return {
     initialized: state.ui.initialized,
     pausedExecutionPoint: getPausedExecutionPoint(state),
     messages: getAllMessagesById(state),
     visibleMessages: getVisibleMessages(state),
     messagesUi: getAllMessagesUiById(state),
     messagesTableData: getAllMessagesTableDataById(state),
     messagesRepeat: getAllRepeatById(state),
+    warningGroups: getAllWarningGroupsById(state),
+    isInWarningGroup: state.prefs.groupWarnings
+      ? message => isMessageInWarningGroup(state, message)
+      : null,
     networkMessagesUpdate: getAllNetworkMessagesUpdateById(state),
     timestampsVisible: state.ui.timestampsVisible,
     networkMessageActiveTabId: state.ui.networkMessageActiveTabId,
   };
 }
 
 module.exports = connect(mapStateToProps)(ConsoleOutput);
--- a/devtools/client/webconsole/reducers/messages.js
+++ b/devtools/client/webconsole/reducers/messages.js
@@ -16,16 +16,20 @@ const {
   FILTERS,
   MESSAGE_TYPE,
   MESSAGE_SOURCE,
 } = constants;
 
 loader.lazyRequireGetter(this, "getGripPreviewItems", "devtools/client/shared/components/reps/reps", true);
 loader.lazyRequireGetter(this, "getUnicodeUrlPath", "devtools/client/shared/unicode-url", true);
 loader.lazyRequireGetter(this, "getSourceNames", "devtools/client/shared/source-utils", true);
+loader.lazyRequireGetter(this, "createWarningGroupMessage", "devtools/client/webconsole/utils/messages", true);
+loader.lazyRequireGetter(this, "isWarningGroup", "devtools/client/webconsole/utils/messages", true);
+loader.lazyRequireGetter(this, "getWarningGroupType", "devtools/client/webconsole/utils/messages", true);
+loader.lazyRequireGetter(this, "getParentWarningGroupMessageId", "devtools/client/webconsole/utils/messages", true);
 
 const {
   UPDATE_REQUEST,
 } = require("devtools/client/netmonitor/src/constants");
 
 const {
   processNetworkUpdates,
 } = require("devtools/client/netmonitor/src/utils/request-utils");
@@ -41,19 +45,22 @@ const MessageState = overrides => Object
   filteredMessagesCount: getDefaultFiltersCounter(),
   // List of the message ids which are opened.
   messagesUiById: [],
   // Map of the form {messageId : tableData}, which represent the data passed
   // as an argument in console.table calls.
   messagesTableDataById: new Map(),
   // Map of the form {groupMessageId : groupArray},
   // where groupArray is the list of of all the parent groups' ids of the groupMessageId.
+  // This handles console API groups.
   groupsById: new Map(),
-  // Message id of the current group (no corresponding console.groupEnd yet).
+  // Message id of the current console API group (no corresponding console.groupEnd yet).
   currentGroup: null,
+  // This group handles "warning groups" (Content Blocking, CORS, CSP, …)
+  warningGroupsById: new Map(),
   // Array of removed actors (i.e. actors logged in removed messages) we keep track of
   // in order to properly release them.
   // This array is not supposed to be consumed by any UI component.
   removedActors: [],
   // Map of the form {messageId : numberOfRepeat}
   repeatById: {},
   // Map of the form {messageId : networkInformation}
   // `networkInformation` holds request, response, totalTime, ...
@@ -73,20 +80,30 @@ function cloneState(state) {
     messagesTableDataById: new Map(state.messagesTableDataById),
     groupsById: new Map(state.groupsById),
     currentGroup: state.currentGroup,
     removedActors: [...state.removedActors],
     repeatById: {...state.repeatById},
     networkMessagesUpdateById: {...state.networkMessagesUpdateById},
     removedLogpointIds: new Set(state.removedLogpointIds),
     pausedExecutionPoint: state.pausedExecutionPoint,
+    warningGroupsById: new Map(state.warningGroupsById),
   };
 }
 
-function addMessage(state, filtersState, prefsState, newMessage) {
+/**
+ * Add a console message to the state.
+ *
+ * @param {ConsoleMessage} newMessage: The message to add to the state.
+ * @param {MessageState} state: The message state ( = managed by this reducer).
+ * @param {FiltersState} filtersState: The filters state.
+ * @param {PrefsState} prefsState: The preferences state.
+ * @returns {MessageState} a new messages state.
+ */
+function addMessage(newMessage, state, filtersState, prefsState) {
   const {
     messagesById,
     replayProgressMessages,
     groupsById,
     currentGroup,
     repeatById,
   } = state;
 
@@ -116,17 +133,17 @@ function addMessage(state, filtersState,
 
   if (newMessage.type === constants.MESSAGE_TYPE.END_GROUP) {
     // Compute the new current group.
     state.currentGroup = getNewCurrentGroup(currentGroup, groupsById);
     return state;
   }
 
   if (newMessage.allowRepeating && messagesById.size > 0) {
-    const lastMessage = [...messagesById.values()][messagesById.size - 1];
+    const lastMessage = messagesById.get(getLastMessageId(state));
 
     if (
       lastMessage.repeatId === newMessage.repeatId
       && lastMessage.groupId === currentGroup
     ) {
       state.repeatById[lastMessage.id] = (repeatById[lastMessage.id] || 1) + 1;
       return state;
     }
@@ -134,39 +151,102 @@ function addMessage(state, filtersState,
 
   // Add the new message with a reference to the parent group.
   const parentGroups = getParentGroups(currentGroup, groupsById);
   newMessage.groupId = currentGroup;
   newMessage.indent = parentGroups.length;
 
   ensureExecutionPoint(state, newMessage);
 
+  // Check if the current message could be placed in a Warning Group.
+  // This needs to be done before setting the new message in messagesById so we have a
+  // proper message.
+  const warningGroupType = getWarningGroupType(newMessage);
+
+  // If the preference for warning grouping is true, and the new message could be in a
+  // warning group.
+  if (prefsState.groupWarnings && warningGroupType !== null) {
+    const warningGroupMessageId = getParentWarningGroupMessageId(newMessage);
+
+    // If there's no warning group for the type/innerWindowID yet
+    if (!state.messagesById.has(warningGroupMessageId)) {
+      // We create it and add it to the store.
+      const groupMessage = createWarningGroupMessage(
+        warningGroupMessageId, warningGroupType, newMessage);
+      state = addMessage(groupMessage, state, filtersState, prefsState);
+      state.warningGroupsById.set(warningGroupMessageId, []);
+    }
+
+    // We add the new message to the appropriate warningGroup.
+    state.warningGroupsById.get(warningGroupMessageId).push(newMessage.id);
+
+    // If the warningGroup message is not visible yet, but should be.
+    if (!state.visibleMessages.includes(warningGroupMessageId)
+      && getMessageVisibility(state.messagesById.get(warningGroupMessageId), {
+        messagesState: state,
+        filtersState,
+        prefsState,
+      }).visible
+    ) {
+      // Then we put it in the visibleMessages properties, at the position of the first
+      // warning message inside the warningGroup.
+      // TODO [Bug 1534927]: It should be added before the outermost console.group message
+      // a warning message could be in.
+      const index = state
+        .visibleMessages
+        .indexOf(state.warningGroupsById.get(warningGroupMessageId)[0]);
+      state.visibleMessages.splice(index, 1, warningGroupMessageId);
+    }
+  }
+
   const addedMessage = Object.freeze(newMessage);
   state.messagesById.set(newMessage.id, addedMessage);
 
   if (newMessage.type === "trace") {
     // We want the stacktrace to be open by default.
     state.messagesUiById.push(newMessage.id);
   } else if (isGroupType(newMessage.type)) {
     state.currentGroup = newMessage.id;
     state.groupsById.set(newMessage.id, parentGroups);
 
     if (newMessage.type === constants.MESSAGE_TYPE.START_GROUP) {
       // We want the group to be open by default.
       state.messagesUiById.push(newMessage.id);
     }
   }
 
-  const {
-    visible,
-    cause,
-  } = getMessageVisibility(addedMessage, state, filtersState);
+  const { visible, cause } = getMessageVisibility(addedMessage, {
+    messagesState: state,
+    filtersState,
+    prefsState,
+  });
 
   if (visible) {
-    state.visibleMessages.push(newMessage.id);
+    // If the message is part of a visible warning group, we want to add it after the last
+    // visible message of the group.
+    const warningGroupId = getParentWarningGroupMessageId(newMessage);
+    if (warningGroupId && state.visibleMessages.includes(warningGroupId)) {
+      // Defaults to the warning group message.
+      let index = state.visibleMessages.indexOf(warningGroupId);
+
+      // We loop backward through the warning group's messages to get the latest visible
+      // messages in it.
+      const messagesInWarningGroup = state.warningGroupsById.get(warningGroupId);
+      for (let i = messagesInWarningGroup.length - 1; i >= 0; i--) {
+        const idx = state.visibleMessages.indexOf(messagesInWarningGroup[i]);
+        if (idx > -1) {
+          index = idx;
+          break;
+        }
+      }
+      // Inserts the new warning message at the wanted location "in" the warning group.
+      state.visibleMessages.splice(index + 1, 0, newMessage.id);
+    } else {
+      state.visibleMessages.push(newMessage.id);
+    }
     maybeSortVisibleMessages(state);
   } else if (DEFAULT_FILTERS.includes(cause)) {
     state.filteredMessagesCount.global++;
     state.filteredMessagesCount[cause]++;
   }
 
   // Append received network-data also into networkMessagesUpdateById
   // that is responsible for collecting (lazy loaded) HTTP payload data.
@@ -217,17 +297,17 @@ function messages(state = MessageState()
         } else {
           list.unshift(message);
         }
         lastMessageRepeatId = message.repeatId;
       }
 
       newState = cloneState(state);
       list.forEach(message => {
-        newState = addMessage(newState, filtersState, prefsState, message);
+        newState = addMessage(message, newState, filtersState, prefsState);
       });
 
       return limitTopLevelMessageCount(newState, logLimit);
 
     case constants.MESSAGES_CLEAR:
       return MessageState({
         // Store all actors from removed messages. This array is used by
         // `releaseActorsEnhancer` to release all of those backend actors.
@@ -273,31 +353,37 @@ function messages(state = MessageState()
       }, removedIds);
     }
 
     case constants.MESSAGE_OPEN:
       const openState = {...state};
       openState.messagesUiById = [...messagesUiById, action.id];
       const currMessage = messagesById.get(action.id);
 
-      // If the message is a group
-      if (isGroupType(currMessage.type)) {
+      // If the message is a console.group/groupCollapsed or a warning group.
+      if (isGroupType(currMessage.type) || isWarningGroup(currMessage)) {
         // We want to make its children visible
         const messagesToShow = [...messagesById].reduce((res, [id, message]) => {
           if (
             !visibleMessages.includes(message.id)
-            && getParentGroups(message.groupId, groupsById).includes(action.id)
-            && getMessageVisibility(
-              message,
-              openState,
+            && (
+              (isWarningGroup(currMessage) && !!getWarningGroupType(message))
+              || (
+                isGroupType(currMessage.type)
+                && getParentGroups(message.groupId, groupsById).includes(action.id)
+              )
+            )
+            && getMessageVisibility(message, {
+              messagesState: openState,
               filtersState,
-              // We want to check if the message is in an open group
-              // only if it is not a direct child of the group we're opening.
-              message.groupId !== action.id
-            ).visible
+              prefsState,
+            // We want to check if the message is in an open group
+            // only if it is not a direct child of the group we're opening.
+              checkGroup: message.groupId !== action.id,
+            }).visible
           ) {
             res.push(id);
           }
           return res;
         }, []);
 
         // We can then insert the messages ids right after the one of the group.
         const insertIndex = visibleMessages.indexOf(action.id) + 1;
@@ -329,16 +415,21 @@ function messages(state = MessageState()
 
       // If the message is a group
       if (isGroupType(messagesById.get(messageId).type)) {
         // Hide all its children
         closeState.visibleMessages = visibleMessages.filter(id =>
           getParentGroups(messagesById.get(id).groupId, groupsById)
             .includes(messageId) === false
         );
+      } else if (isWarningGroup(messagesById.get(messageId))) {
+        // If the message was a warningGroup, we hide all the messages in the group.
+        const groupMessages = closeState.warningGroupsById.get(messageId);
+        closeState.visibleMessages =
+          visibleMessages.filter(id => !groupMessages.includes(id));
       }
       return closeState;
 
     case constants.MESSAGE_TABLE_RECEIVE:
       const {id, data} = action;
 
       return {
         ...state,
@@ -382,20 +473,22 @@ function messages(state = MessageState()
     case constants.FILTER_TOGGLE:
     case constants.FILTER_TEXT_SET:
     case constants.FILTERS_CLEAR:
     case constants.DEFAULT_FILTERS_RESET:
       const messagesToShow = [];
       const filtered = getDefaultFiltersCounter();
 
       messagesById.forEach((message, msgId) => {
-        const {
-          visible,
-          cause,
-        } = getMessageVisibility(message, state, filtersState);
+        const { visible, cause } = getMessageVisibility(message, {
+          messagesState: state,
+          filtersState,
+          prefsState,
+        });
+
         if (visible) {
           messagesToShow.push(msgId);
         } else if (DEFAULT_FILTERS.includes(cause)) {
           filtered.global = filtered.global + 1;
           filtered[cause] = filtered[cause] + 1;
         }
       });
 
@@ -630,28 +723,57 @@ function getToplevelMessageCount(state) 
 /**
  * Check if a message should be visible in the console output, and if not, what
  * causes it to be hidden.
  *
  * @return {Object} An object of the following form:
  *         - visible {Boolean}: true if the message should be visible
  *         - cause {String}: if visible is false, what causes the message to be hidden.
  */
-function getMessageVisibility(message, messagesState, filtersState, checkGroup = true) {
+function getMessageVisibility(message, {
+    messagesState,
+    filtersState,
+    prefsState,
+    checkGroup = true,
+}) {
   // Do not display the message if it's in closed group.
   if (
     checkGroup
     && !isInOpenedGroup(message, messagesState.groupsById, messagesState.messagesUiById)
   ) {
     return {
       visible: false,
       cause: "closedGroup",
     };
   }
 
+  // If the message is a warningGroup, check if it should be displayed.
+  if (
+    isWarningGroup(message)
+    && !shouldGroupWarningMessages(message, messagesState, prefsState)
+  ) {
+    return {
+      visible: false,
+      cause: "warningGroupHeuristicNotMet",
+    };
+  }
+
+  // Do not display the the message if it can be in a warningGroup, and the group is
+  // displayed but collapsed.
+  const warningGroupMessageId = getParentWarningGroupMessageId(message);
+  if (
+    messagesState.visibleMessages.includes(warningGroupMessageId)
+    && !messagesState.messagesUiById.includes(warningGroupMessageId)
+  ) {
+    return {
+      visible: false,
+      cause: "closedWarningGroup",
+    };
+  }
+
   // Some messages can't be filtered out (e.g. groups).
   // So, always return visible: true for those.
   if (isUnfilterable(message)) {
     return {
       visible: true,
     };
   }
 
@@ -1044,12 +1166,34 @@ function maybeSortVisibleMessages(state)
       // have an execution point.
       const countA = messageCountSinceLastExecutionPoint(state, a);
       const countB = messageCountSinceLastExecutionPoint(state, b);
       return countA > countB;
     });
   }
 }
 
+function getLastMessageId(state) {
+  return Array.from(state.messagesById.keys())[state.messagesById.size - 1];
+}
+
+/**
+ * Returns if a given type of warning message should be grouped.
+ *
+ * @param {ConsoleMessage} warningGroupMessage
+ * @param {MessageState} messagesState
+ * @param {PrefsState} prefsState
+ */
+function shouldGroupWarningMessages(warningGroupMessage, messagesState, prefsState) {
+  // Only group if the preference is ON.
+  if (!prefsState.groupWarnings) {
+    return false;
+  }
+
+  // We group warning messages if there are at least 2 messages that could go in it.
+  const warningGroup = messagesState.warningGroupsById.get(warningGroupMessage.id);
+  return warningGroup && warningGroup.length > 1;
+}
+
 exports.messages = messages;
 
 // Export for testing purpose.
 exports.ensureExecutionPoint = ensureExecutionPoint;
--- a/devtools/client/webconsole/selectors/messages.js
+++ b/devtools/client/webconsole/selectors/messages.js
@@ -1,15 +1,18 @@
 /* -*- 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";
 
+loader.lazyRequireGetter(this, "getWarningGroupType", "devtools/client/webconsole/utils/messages", true);
+loader.lazyRequireGetter(this, "getParentWarningGroupMessageId", "devtools/client/webconsole/utils/messages", true);
+
 function getAllMessagesById(state) {
   return state.messages.messagesById;
 }
 
 function getMessage(state, id) {
   return getAllMessagesById(state).get(id);
 }
 
@@ -48,22 +51,36 @@ function getAllNetworkMessagesUpdateById
 function getGroupsById(state) {
   return state.messages.groupsById;
 }
 
 function getPausedExecutionPoint(state) {
   return state.messages.pausedExecutionPoint;
 }
 
+function getAllWarningGroupsById(state) {
+  return state.messages.warningGroupsById;
+}
+
+function isMessageInWarningGroup(state, message) {
+  if (!getWarningGroupType(message)) {
+    return false;
+  }
+
+  return getVisibleMessages(state).includes(getParentWarningGroupMessageId(message));
+}
+
 module.exports = {
   getAllGroupsById,
+  getAllWarningGroupsById,
   getAllMessagesById,
   getAllMessagesTableDataById,
   getAllMessagesUiById,
   getAllNetworkMessagesUpdateById,
   getAllRepeatById,
   getCurrentGroup,
   getFilteredMessagesCount,
   getGroupsById,
   getMessage,
   getVisibleMessages,
   getPausedExecutionPoint,
+  isMessageInWarningGroup,
 };
--- a/devtools/client/webconsole/test/components/warning-group.test.js
+++ b/devtools/client/webconsole/test/components/warning-group.test.js
@@ -3,54 +3,73 @@
 "use strict";
 
 // Test utils.
 const expect = require("expect");
 const { render } = require("enzyme");
 
 // Components under test.
 const WarningGroup = require("devtools/client/webconsole/components/message-types/WarningGroup");
-const { MESSAGE_SOURCE } = require("devtools/client/webconsole/constants");
+const { MESSAGE_SOURCE, MESSAGE_TYPE } = require("devtools/client/webconsole/constants");
 const { ConsoleMessage } = require("devtools/client/webconsole/types");
+const { createWarningGroupMessage } = require("devtools/client/webconsole/utils/messages");
 
 // Test fakes.
+const { stubPreparedMessages } = require("devtools/client/webconsole/test/fixtures/stubs/index");
 const serviceContainer = require("devtools/client/webconsole/test/fixtures/serviceContainer");
-const message = ConsoleMessage({
+const mockMessage = ConsoleMessage({
   messageText: "this is a warning group",
   source: MESSAGE_SOURCE.CONSOLE_FRONTEND,
   timeStamp: Date.now(),
 });
 
 describe("WarningGroup component:", () => {
   it("renders", () => {
     const wrapper = render(WarningGroup({
-      message,
+      message: mockMessage,
       serviceContainer,
       timestampsVisible: true,
       badge: 42,
     }));
 
     const { timestampString } = require("devtools/client/webconsole/webconsole-l10n");
-    expect(wrapper.find(".timestamp").text()).toBe(timestampString(message.timeStamp));
+    expect(wrapper.find(".timestamp").text())
+      .toBe(timestampString(mockMessage.timeStamp));
     expect(wrapper.find(".message-body").text()).toBe("this is a warning group 42");
     expect(wrapper.find(".arrow[aria-expanded=false]")).toExist();
   });
 
   it("does have an expanded arrow when `open` prop is true", () => {
     const wrapper = render(WarningGroup({
-      message,
+      message: mockMessage,
       serviceContainer,
       open: true,
     }));
 
     expect(wrapper.find(".arrow[aria-expanded=true]")).toExist();
   });
 
   it("does not have a timestamp when timestampsVisible prop is falsy", () => {
     const wrapper = render(WarningGroup({
-      message,
+      message: mockMessage,
       serviceContainer,
       timestampsVisible: false,
     }));
 
     expect(wrapper.find(".timestamp").length).toBe(0);
   });
+
+  it("renders Content Blocking Group message", () => {
+    const firstMessage = stubPreparedMessages.get("ReferenceError: asdf is not defined");
+    const type = MESSAGE_TYPE.CONTENT_BLOCKING_GROUP;
+    const message = createWarningGroupMessage(`${type}-${firstMessage.innerWindowID}`,
+      type, firstMessage);
+
+    const wrapper = render(WarningGroup({
+      message,
+      serviceContainer,
+      badge: 24,
+    }));
+
+    expect(wrapper.find(".message-body").text()).toBe("Content blocked messages 24");
+    expect(wrapper.find(".arrow[aria-expanded=false]")).toExist();
+  });
 });
--- a/devtools/client/webconsole/test/fixtures/L10n.js
+++ b/devtools/client/webconsole/test/fixtures/L10n.js
@@ -36,16 +36,18 @@ class L10n {
       case "webconsole.xhrFilterButton.label":
         return "XHR";
       case "webconsole.requestsFilterButton.label":
         return "Requests";
       case "messageRepeats.tooltip2":
         return "#1 repeat;#1 repeats";
       case "webconsole.filteredMessages.label":
         return "#1 item hidden by filters;#1 items hidden by filters";
+      case "webconsole.group.contentBlocked":
+        return "Content blocked messages";
       default:
         return str;
     }
   }
 
   getFormatStr(str) {
     return this.getStr(str);
   }
--- a/devtools/client/webconsole/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/test/mochitest/browser.ini
@@ -110,16 +110,17 @@ support-files =
   test-sourcemap-error-02.html
   test-sourcemap-error-02.js
   test-stacktrace-location-debugger-link.html
   test-subresource-security-error.html
   test-subresource-security-error.js
   test-subresource-security-error.js^headers^
   test-time-methods.html
   test-trackingprotection-securityerrors.html
+  test-warning-groups.html
   test-webconsole-error-observer.html
   test-websocket.html
   test-websocket.js
   testscript.js
   !/devtools/client/netmonitor/test/sjs_cors-test-server.sjs
   !/image/test/mochitest/blue.png
   !/devtools/client/shared/test/shared-head.js
   !/devtools/client/debugger/new/test/mochitest/helpers.js
@@ -392,9 +393,11 @@ skip-if = verify
 [browser_webconsole_telemetry_object_expanded.js]
 [browser_webconsole_time_methods.js]
 [browser_webconsole_timestamps.js]
 [browser_webconsole_trackingprotection_errors.js]
 tags = trackingprotection
 [browser_webconsole_view_source.js]
 [browser_webconsole_visibility_messages.js]
 [browser_webconsole_warn_about_replaced_api.js]
+[browser_webconsole_warning_group_content_blocking.js]
+[browser_webconsole_warning_groups.js]
 [browser_webconsole_websocket.js]
--- a/devtools/client/webconsole/test/mochitest/browser_webconsole_trackingprotection_errors.js
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_trackingprotection_errors.js
@@ -113,24 +113,16 @@ add_task(async function testCookieBlocke
   await testLearnMoreClickOpenNewTab(message,
     getStorageErrorUrl("CookieBlockedByPermission"));
   win.close();
 
   // Remove the custom permission.
   Services.perms.removeFromPrincipal(p, "cookie");
 });
 
-async function openNewWindowAndConsole(url) {
-  const win = await openNewBrowserWindow();
-  const tab = await addTab(url, {window: win});
-  win.gBrowser.selectedTab = tab;
-  const hud = await openConsole(tab);
-  return {win, hud};
-}
-
 function getStorageErrorUrl(category) {
   const BASE_STORAGE_ERROR_URL = "https://developer.mozilla.org/docs/Mozilla/Firefox/" +
                                  "Privacy/Storage_access_policy/Errors/";
   const STORAGE_ERROR_URL_PARAMS = new URLSearchParams({
     utm_source: "devtools",
     utm_medium: "firefox-cookie-errors",
     utm_campaign: "default",
   }).toString();
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_warning_group_content_blocking.js
@@ -0,0 +1,194 @@
+/* -*- 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/ */
+
+// Load a page with tracking elements that get blocked and make sure that a
+// 'learn more' link shows up in the webconsole.
+
+"use strict";
+requestLongerTimeout(2);
+
+const TEST_FILE =
+  "browser/devtools/client/webconsole/test/mochitest/test-warning-groups.html";
+const TEST_URI = "http://example.com/" + TEST_FILE;
+
+const TRACKER_URL = "http://tracking.example.com/";
+const IMG_FILE = "browser/devtools/client/webconsole/test/mochitest/test-image.png";
+const TRACKER_IMG = "http://tracking.example.org/" + IMG_FILE;
+
+const CONTENT_BLOCKING_GROUP_LABEL = "Content blocked messages";
+
+const COOKIE_BEHAVIOR_PREF = "network.cookie.cookieBehavior";
+const COOKIE_BEHAVIORS = {
+// reject all third-party cookies
+  REJECT_FOREIGN: 1,
+// reject all cookies
+  REJECT: 2,
+// reject third-party cookies unless the eTLD already has at least one cookie
+  LIMIT_FOREIGN: 3,
+// reject trackers
+  REJECT_TRACKER: 4,
+};
+
+const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+UrlClassifierTestUtils.addTestTrackers();
+registerCleanupFunction(function() {
+  UrlClassifierTestUtils.cleanupTestTrackers();
+});
+
+pushPref("privacy.trackingprotection.enabled", true);
+pushPref("devtools.webconsole.groupWarningMessages", true);
+
+add_task(async function testContentBlockingMessage() {
+  const {hud, tab, win} = await openNewWindowAndConsole(
+    "http://tracking.example.org/" + TEST_FILE);
+  const now = Date.now();
+
+  info("Test content blocking message");
+  const message = `The resource at \u201chttp://tracking.example.com/?1&${now}\u201d ` +
+    `was blocked because content blocking is enabled`;
+  const onContentBlockingWarningMessage = waitForMessage(hud, message, ".warn");
+  emitContentBlockingMessage(tab, `${TRACKER_URL}?1&${now}`);
+  await onContentBlockingWarningMessage;
+
+  ok(true, "The content blocking message was displayed");
+
+  info("Emit a new content blocking message to check that it causes a grouping");
+  const onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitContentBlockingMessage(tab, `${TRACKER_URL}?2&${now}`);
+  const {node} = await onContentBlockingWarningGroupMessage;
+  is(node.querySelector(".warning-group-badge").textContent, "2",
+    "The badge has the expected text");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL} 2`,
+  ]);
+
+  info("Open the group");
+  node.querySelector(".arrow").click();
+  await waitFor(() => findMessage(hud, "http://tracking.example.com/?1"));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL} 2`,
+    `| The resource at \u201chttp://tracking.example.com/?1&${now}\u201d was blocked`,
+    `| The resource at \u201chttp://tracking.example.com/?2&${now}\u201d was blocked`,
+  ]);
+  await win.close();
+});
+
+add_task(async function testForeignCookieBlockedMessage() {
+  info("Test foreign cookie blocked message");
+  // We change the pref and open a new window to ensure it will be taken into account.
+  await pushPref(COOKIE_BEHAVIOR_PREF, COOKIE_BEHAVIORS.REJECT_FOREIGN);
+  const getWarningMsg = url => `Request to access cookie or storage on “${url}” was ` +
+    `blocked because we are blocking all third-party`;
+  await testStorageAccessBlockedGrouping(getWarningMsg);
+});
+
+add_task(async function testLimitForeignCookieBlockedMessage() {
+  info("Test unvisited eTLD foreign cookies blocked message");
+  // We change the pref and open a new window to ensure it will be taken into account.
+  await pushPref(COOKIE_BEHAVIOR_PREF, COOKIE_BEHAVIORS.LIMIT_FOREIGN);
+  const getWarningMsg = url => `Request to access cookie or storage on “${url}” was ` +
+    `blocked because we are blocking all third-party`;
+  await testStorageAccessBlockedGrouping(getWarningMsg);
+});
+
+add_task(async function testAllCookieBlockedMessage() {
+  info("Test all cookies blocked message");
+  // We change the pref and open a new window to ensure it will be taken into account.
+  await pushPref(COOKIE_BEHAVIOR_PREF, COOKIE_BEHAVIORS.REJECT);
+  const getWarningMsg = url => `Request to access cookie or storage on “${url}” was ` +
+    `blocked because we are blocking all storage access requests`;
+  await testStorageAccessBlockedGrouping(getWarningMsg);
+});
+
+add_task(async function testTrackerCookieBlockedMessage() {
+  info("Test tracker cookie blocked message");
+  // We change the pref and open a new window to ensure it will be taken into account.
+  await pushPref(COOKIE_BEHAVIOR_PREF, COOKIE_BEHAVIORS.REJECT_TRACKER);
+  const getWarningMsg = url => `Request to access cookie or storage on “${url}” was ` +
+    `blocked because it came from a tracker`;
+  await testStorageAccessBlockedGrouping(getWarningMsg);
+});
+
+add_task(async function testCookieBlockedByPermissionMessage() {
+  info("Test cookie blocked by permission message");
+  // Turn off tracking protection and add a block permission on the URL.
+  await pushPref("privacy.trackingprotection.enabled", false);
+  const p = Services.scriptSecurityManager.createCodebasePrincipalFromOrigin("http://tracking.example.org/");
+  Services.perms.addFromPrincipal(p, "cookie", Ci.nsIPermissionManager.DENY_ACTION);
+
+  const getWarningMsg = url => `Request to access cookies or storage on “${url}” was ` +
+    `blocked because of custom cookie permission`;
+  await testStorageAccessBlockedGrouping(getWarningMsg);
+
+  // Remove the custom permission.
+  Services.perms.removeFromPrincipal(p, "cookie");
+});
+
+/**
+ * Test that storage access blocked messages are grouped by emitting 2 messages.
+ *
+ * @param {Function} getWarningMessage: A function that takes an URL string as a parameter
+ *                                  and returns the corresponding warning message.
+ */
+async function testStorageAccessBlockedGrouping(getWarningMessage) {
+  const {hud, win, tab} = await openNewWindowAndConsole(TEST_URI);
+  const now = Date.now();
+
+  hud.ui.clearOutput();
+  const onStorageAccessBlockedMessage =
+    waitForMessage(hud, getWarningMessage(`${TRACKER_IMG}?1&${now}`), ".warn");
+  emitStorageAccessBlockedMessage(tab, `${TRACKER_IMG}?1&${now}`);
+  await onStorageAccessBlockedMessage;
+
+  info("Emit a new content blocking message to check that it causes a grouping");
+  const onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitStorageAccessBlockedMessage(tab, `${TRACKER_IMG}?2&${now}`);
+  const {node} = await onContentBlockingWarningGroupMessage;
+  is(node.querySelector(".warning-group-badge").textContent, "2",
+    "The badge has the expected text");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL} 2`,
+  ]);
+
+  info("Open the group");
+  node.querySelector(".arrow").click();
+  await waitFor(() => findMessage(hud, TRACKER_IMG));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL} 2`,
+    `| ${getWarningMessage(TRACKER_IMG + "?1&" + now)}`,
+    `| ${getWarningMessage(TRACKER_IMG + "?2&" + now)}`,
+  ]);
+
+  hud.ui.clearOutput();
+  await win.close();
+}
+
+/**
+ * Emit a Content Blocking message. This is done by loading an iframe from an origin
+ * tagged as tracker. The image is loaded with a incremented counter query parameter
+ * each time so we can get the warning message.
+ */
+function emitContentBlockingMessage(tab, url) {
+  ContentTask.spawn(tab.linkedBrowser, url, function(innerURL) {
+    content.wrappedJSObject.loadIframe(innerURL);
+  });
+}
+
+/**
+ * Emit a Storage blocked message. This is done by loading an image from an origin
+ * tagged as tracker. The image is loaded with a incremented counter query parameter
+ * each time so we can get the warning message.
+ */
+function emitStorageAccessBlockedMessage(tab, url) {
+  ContentTask.spawn(tab.linkedBrowser, url, async function(innerURL) {
+    content.wrappedJSObject.loadImage(innerURL);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_warning_groups.js
@@ -0,0 +1,241 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that warning messages can be grouped, per navigation and category, and that
+// interacting with these groups works as expected.
+
+"use strict";
+requestLongerTimeout(2);
+
+const TEST_FILE =
+  "browser/devtools/client/webconsole/test/mochitest/test-warning-groups.html";
+const TEST_URI = "http://example.com/" + TEST_FILE;
+
+const TRACKER_URL = "http://tracking.example.org/";
+const BLOCKED_URL = TRACKER_URL +
+  "browser/devtools/client/webconsole/test/mochitest/test-image.png";
+
+const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm");
+registerCleanupFunction(function() {
+  UrlClassifierTestUtils.cleanupTestTrackers();
+});
+
+add_task(async function testContentBlockingMessage() {
+  const CONTENT_BLOCKING_GROUP_LABEL = "Content blocked messages";
+
+  // Tracking protection preferences
+  await UrlClassifierTestUtils.addTestTrackers();
+  await pushPref("privacy.trackingprotection.enabled", true);
+
+  // Enable groupWarning and persist log
+  await pushPref("devtools.webconsole.groupWarningMessages", true);
+  await pushPref("devtools.webconsole.persistlog", true);
+
+  const hud = await openNewTabAndConsole(TEST_URI);
+
+  info("Log a tracking protection message to check a single message isn't grouped");
+  let onContentBlockingWarningMessage = waitForMessage(hud, BLOCKED_URL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  let {node} = await onContentBlockingWarningMessage;
+  is(node.querySelector(".warning-indent"), null, "The message has the expected style");
+  is(node.querySelector(".indent").getAttribute("data-indent"), "0",
+    "The message has the expected indent");
+
+  info("Log a simple message");
+  await logString(hud, "simple message 1");
+
+  info("Log a second tracking protection message to check that it causes the grouping");
+  let onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  ({node} = await onContentBlockingWarningGroupMessage);
+  is(node.querySelector(".warning-group-badge").textContent, "2",
+    "The badge has the expected text");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 1`,
+  ]);
+
+  info("Log another simple message");
+  await logString(hud, "simple message 2");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 1`,
+    `simple message 2`,
+  ]);
+
+  info("Log a third tracking protection message to check that the badge updates");
+  emitStorageAccessBlockedMessage(hud);
+  await waitFor(() => node.querySelector(".warning-group-badge").textContent == "3");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 1`,
+    `simple message 2`,
+  ]);
+
+  info("Open the group");
+  node.querySelector(".arrow").click();
+  await waitFor(() => findMessage(hud, BLOCKED_URL));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `simple message 1`,
+    `simple message 2`,
+  ]);
+
+  info("Log a new tracking protection message to check it appears inside the group");
+  onContentBlockingWarningMessage =
+    waitForMessage(hud, BLOCKED_URL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  await onContentBlockingWarningMessage;
+  ok(true, "The new tracking protection message is displayed");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+  ]);
+
+  info("Reload the page and wait for it to be ready");
+  const onDomContentLoaded = BrowserTestUtils.waitForContentEvent(
+    hud.target.tab.linkedBrowser, "DOMContentLoaded", true);
+  ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.location.reload();
+  });
+  await onDomContentLoaded;
+
+  // Also wait for the navigation message to be displayed.
+  await waitFor(() => findMessage(hud, "Navigated to"));
+
+  info("Log a tracking protection message to check it is not grouped");
+  onContentBlockingWarningMessage =
+    waitForMessage(hud, BLOCKED_URL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  await onContentBlockingWarningMessage;
+
+  await logString(hud, "simple message 3");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+    "Navigated to",
+    `${BLOCKED_URL}?5`,
+    `simple message 3`,
+  ]);
+
+  info("Log a second tracking protection message to check that it causes the grouping");
+  onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  ({node} = await onContentBlockingWarningGroupMessage);
+  is(node.querySelector(".warning-group-badge").textContent, "2",
+    "The badge has the expected text");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 3`,
+  ]);
+
+  info("Check that opening this group works");
+  node.querySelector(".arrow").click();
+  await waitFor(() => findMessages(hud, BLOCKED_URL).length === 6);
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?5`,
+    `| ${BLOCKED_URL}?6`,
+    `simple message 3`,
+  ]);
+
+  info("Check that closing this group works, and let the other one open");
+  node.querySelector(".arrow").click();
+  await waitFor(() => findMessages(hud, BLOCKED_URL).length === 4);
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 3`,
+  ]);
+
+  info("Log a third tracking protection message to check that the badge updates");
+  emitStorageAccessBlockedMessage(hud);
+  await waitFor(() => node.querySelector(".warning-group-badge").textContent == "3");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▶︎ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 3`,
+  ]);
+});
+
+let cpt = 0;
+/**
+ * Emit a Content Blocking message. This is done by loading an image from an origin
+ * tagged as tracker. The image is loaded with a incremented counter query parameter
+ * each time so we can get the warning message.
+ */
+function emitStorageAccessBlockedMessage() {
+  const url = `${BLOCKED_URL}?${++cpt}`;
+  ContentTask.spawn(gBrowser.selectedBrowser, url, function(innerURL) {
+    content.wrappedJSObject.loadImage(innerURL);
+  });
+}
+
+/**
+ * Log a string from the content page.
+ *
+ * @param {WebConsole} hud
+ * @param {String} str
+ */
+function logString(hud, str) {
+  const onMessage = waitForMessage(hud, str);
+  ContentTask.spawn(gBrowser.selectedBrowser, str, function(arg) {
+    content.console.log(arg);
+  });
+  return onMessage;
+}
--- a/devtools/client/webconsole/test/mochitest/head.js
+++ b/devtools/client/webconsole/test/mochitest/head.js
@@ -82,16 +82,33 @@ async function openNewTabAndConsole(url,
     // Clearing history that might have been set in previous tests.
     await hud.ui.wrapper.dispatchClearHistory();
   }
 
   return hud;
 }
 
 /**
+ * Open a new window with a tab,open the toolbox, and select the webconsole.
+ *
+ * @param string url
+ *        The URL for the tab to be opened.
+ * @return Promise<{win, hud, tab}>
+ *         Resolves when the tab has been added, loaded and the toolbox has been opened.
+ *         Resolves to the toolbox.
+ */
+async function openNewWindowAndConsole(url) {
+  const win = await openNewBrowserWindow();
+  const tab = await addTab(url, {window: win});
+  win.gBrowser.selectedTab = tab;
+  const hud = await openConsole(tab);
+  return {win, hud, tab};
+}
+
+/**
  * Subscribe to the store and log out stringinfied versions of messages.
  * This is a helper function for debugging, to make is easier to see what
  * happened during the test in the log.
  *
  * @param object hud
  */
 function logAllStoreChanges(hud) {
   const store = hud.ui.wrapper.getStore();
@@ -1216,8 +1233,48 @@ function hasVerticalOverflow(container) 
 function isScrolledToBottom(container) {
   if (!container.lastChild) {
     return true;
   }
   const lastNodeHeight = container.lastChild.clientHeight;
   return container.scrollTop + container.clientHeight >=
          container.scrollHeight - lastNodeHeight / 2;
 }
+
+/**
+ *
+ * @param {WebConsole} hud
+ * @param {Array<String>} expectedMessages: An array of string representing the messages
+ *                        from the output. This can only be a part of the string of the
+ *                        message.
+ *                        Start the string with "▶︎ " or "▼ " to indicate that the
+ *                        message is a warningGroup (with respectively an open or
+ *                        collapsed arrow).
+ *                        Start the string with "|︎ " to indicate that the message is
+ *                        inside a group and should be indented.
+ */
+function checkConsoleOutputForWarningGroup(hud, expectedMessages) {
+  const messages = findMessages(hud, "");
+  is(messages.length, expectedMessages.length, "Got the expected number of messages");
+  expectedMessages.forEach((expectedMessage, i) => {
+    const message = messages[i];
+    if (expectedMessage.startsWith("▶︎")) {
+      is(message.querySelector(".arrow").getAttribute("aria-expanded"), "false",
+        "There's a collapsed arrow");
+      expectedMessage = expectedMessage.replace("▶︎ ", "");
+    }
+
+    if (expectedMessage.startsWith("▼")) {
+      is(message.querySelector(".arrow").getAttribute("aria-expanded"), "true",
+        "There's an expanded arrow");
+      expectedMessage = expectedMessage.replace("▼︎ ", "");
+    }
+
+    if (expectedMessage.startsWith("|")) {
+      is(message.querySelector(".indent.warning-indent").getAttribute("data-indent"), "1",
+        "The message has the expected indent");
+      expectedMessage = expectedMessage.replace("| ", "");
+    }
+
+    ok(message.textContent.trim().includes(expectedMessage.trim()), `Message includes ` +
+      `the expected "${expectedMessage}" content - "${message.textContent.trim()}"`);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/test-warning-groups.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>Warning groups webconsole test page</title>
+  </head>
+  <body>
+    <p>Warning groups webconsole test page</p>
+    <script>
+      "use strict";
+
+      /* exported loadImage, loadIframe */
+      function loadImage(src) {
+        const img = document.createElement("img");
+        img.src = src;
+        img.alt = (new URL(src)).search;
+        img.title = src;
+        document.body.appendChild(img);
+      }
+
+      function loadIframe(src) {
+        const iframe = document.createElement("iframe");
+        iframe.src = src;
+        document.body.appendChild(iframe);
+      }
+    </script>
+  </body>
+</html>
--- a/devtools/client/webconsole/utils/messages.js
+++ b/devtools/client/webconsole/utils/messages.js
@@ -425,29 +425,86 @@ function isPacketPrivate(packet) {
   return (
     packet.private === true ||
     (packet.message && packet.message.private === true) ||
     (packet.pageError && packet.pageError.private === true) ||
     (packet.networkEvent && packet.networkEvent.private === true)
   );
 }
 
+function createWarningGroupMessage(id, type, firstMessage) {
+  let messageText;
+  if (type === MESSAGE_TYPE.CONTENT_BLOCKING_GROUP) {
+    messageText = l10n.getStr("webconsole.group.contentBlocked");
+  }
+  return new ConsoleMessage({
+    id,
+    level: MESSAGE_LEVEL.WARN,
+    source: MESSAGE_SOURCE.CONSOLE_FRONTEND,
+    type,
+    messageText,
+    timeStamp: firstMessage.timeStamp,
+    innerWindowID: firstMessage.innerWindowID,
+  });
+}
+
+/**
+ * Get the warningGroup type in which the message could be in.
+ * @param {ConsoleMessage} message
+ * @returns {String|null} null if the message can't be part of a warningGroup.
+ */
+function getWarningGroupType(message) {
+  if (isContentBlockingMessage(message)) {
+    return MESSAGE_TYPE.CONTENT_BLOCKING_GROUP;
+  }
+  return null;
+}
+
+/**
+ * Returns a computed id given a message
+ *
+ * @param {ConsoleMessage} type: the message type, from MESSAGE_TYPE.
+ * @param {Integer} innerWindowID: the message innerWindowID.
+ * @returns {String}
+ */
+function getParentWarningGroupMessageId(message) {
+  return `${message.type}-${message.innerWindowID}`;
+}
+
 /**
  * Returns true if the message is a warningGroup message (i.e. the "Header").
  * @param {ConsoleMessage} message
  * @returns {Boolean}
  */
 function isWarningGroup(message) {
-  return message.type === MESSAGE_TYPE.TRACKING_PROTECTION_GROUP
+  return message.type === MESSAGE_TYPE.CONTENT_BLOCKING_GROUP
    || message.type === MESSAGE_TYPE.CORS_GROUP
    || message.type === MESSAGE_TYPE.CSP_GROUP;
 }
 
+/**
+ * Returns true if the message is a content blocking message.
+ * @param {ConsoleMessage} message
+ * @returns {Boolean}
+ */
+function isContentBlockingMessage(message) {
+  const {category} = message;
+  return category == "cookieBlockedPermission" ||
+    category == "cookieBlockedTracker" ||
+    category == "cookieBlockedAll" ||
+    category == "cookieBlockedForeign" ||
+    category == "Tracking Protection";
+}
+
 module.exports = {
+  createWarningGroupMessage,
   getInitialMessageCountForViewport,
+  getParentWarningGroupMessageId,
+  getWarningGroupType,
+  isContentBlockingMessage,
   isGroupType,
   isPacketPrivate,
   isWarningGroup,
   l10n,
   prepareMessage,
   // Export for use in testing.
   getRepeatId,
 };