Bug 1534932 - Ensure filtering works with warningGroups. r=Honza.
authorNicolas Chevobbe <nchevobbe@mozilla.com>
Tue, 21 May 2019 12:43:04 +0000
changeset 474716 78571bb1f20e643cdab9791f61b0aa7b6a8bdf90
parent 474715 be01ec66f386d44d59fc56ab904a84c299521177
child 474731 3c0f78074b727fbae112b6eda111d4c4d30cc3ec
child 474732 c03edb23edaa0f8e8fc5585838dc58e7a96e1cb0
push id36044
push userrmaries@mozilla.com
push dateTue, 21 May 2019 15:45:34 +0000
treeherdermozilla-central@78571bb1f20e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1534932
milestone69.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 1534932 - Ensure filtering works with warningGroups. r=Honza. We want the warningGroup to be displayed if at least one of its children will be visible. We also want all the children if the warningGroup should be visible. This requires a few changes in the message reducers, mainly in the getVisibility function. But we also modify maybeSortVisibleMessages to place the messages in warningGroups at the expected positions. A complete mochitest is added to ensure the output always has the expected messages in the expected order. Differential Revision: https://phabricator.services.mozilla.com/D31220
devtools/client/webconsole/reducers/messages.js
devtools/client/webconsole/test/mochitest/browser.ini
devtools/client/webconsole/test/mochitest/browser_webconsole_warning_groups_filtering.js
--- a/devtools/client/webconsole/reducers/messages.js
+++ b/devtools/client/webconsole/reducers/messages.js
@@ -255,17 +255,17 @@ function addMessage(newMessage, state, f
           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);
+    maybeSortVisibleMessages(state, false);
   } 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.
   if (newMessage.source == "network") {
@@ -529,17 +529,17 @@ function messages(state = MessageState()
         }
       });
 
       const filteredState = {
         ...state,
         visibleMessages: messagesToShow,
         filteredMessagesCount: filtered,
       };
-      maybeSortVisibleMessages(filteredState);
+      maybeSortVisibleMessages(filteredState, true);
 
       return filteredState;
   }
 
   return state;
 }
 
 /**
@@ -763,81 +763,151 @@ function getToplevelMessageCount(state) 
     }
   });
   return count;
 }
 
 /**
  * Check if a message should be visible in the console output, and if not, what
  * causes it to be hidden.
+ * @param {Message} message: The message to check
+ * @param {Object} option: An option object of the following shape:
+ *                   - {MessageState} messagesState: The current messages state
+ *                   - {FilterState} filtersState: The current filters state
+ *                   - {PrefsState} prefsState: The current preferences state
+ *                   - {UiState} uiState: The current ui state
+ *                   - {Boolean} checkGroup: Set to false to not check if a message should
+ *                                 be visible because it is in a console.group.
+ *                   - {Boolean} checkParentWarningGroupVisibility: Set to false to not
+ *                                 check if a message should be visible because it is in a
+ *                                 warningGroup and the warningGroup is visible.
  *
  * @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,
     prefsState,
     uiState,
     checkGroup = true,
+    checkParentWarningGroupVisibility = true,
 }) {
   // Do not display the message if it's not from chromeContext and we don't show content
   // messages.
   if (
     !uiState.showContentMessages &&
     message.chromeContext === false &&
     message.type !== MESSAGE_TYPE.COMMAND &&
     message.type !== MESSAGE_TYPE.RESULT
   ) {
     return {
       visible: false,
       cause: "contentMessage",
     };
   }
 
-  const warningGroupMessage =
-  messagesState.messagesById.get(getParentWarningGroupMessageId(message));
+  const warningGroupMessageId = getParentWarningGroupMessageId(message);
+  const parentWarningGroupMessage = messagesState.messagesById.get(warningGroupMessageId);
 
   // Do not display the message if it's in closed group and not in a warning group.
   if (
     checkGroup
     && !isInOpenedGroup(message, messagesState.groupsById, messagesState.messagesUiById)
-    && !shouldGroupWarningMessages(warningGroupMessage, messagesState, prefsState)
+    && !shouldGroupWarningMessages(parentWarningGroupMessage, messagesState, prefsState)
   ) {
     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",
-    };
+  if (isWarningGroup(message)) {
+    if (!shouldGroupWarningMessages(message, messagesState, prefsState)) {
+      return {
+        visible: false,
+        cause: "warningGroupHeuristicNotMet",
+      };
+    }
+
+    // Hide a warningGroup if the warning filter is off.
+    if (!filtersState[FILTERS.WARN]) {
+      // We don't include any cause as we don't want that message to be reflected in the
+      // message count.
+      return {
+        visible: false,
+      };
+    }
+
+    // Display a warningGroup if at least one of its message will be visible.
+    const childrenMessages = messagesState.warningGroupsById.get(message.id);
+    const hasVisibleChild = childrenMessages && childrenMessages.some(id => {
+      const child = messagesState.messagesById.get(id);
+      if (!child) {
+        return false;
+      }
+
+      const {visible, cause} = getMessageVisibility(child, {
+        messagesState,
+        filtersState,
+        prefsState,
+        uiState,
+        checkParentWarningGroupVisibility: false,
+      });
+      return visible && cause !== "visibleWarningGroup";
+    });
+
+    if (hasVisibleChild) {
+      return {
+        visible: true,
+        cause: "visibleChild",
+      };
+    }
   }
 
-  // Do not display the the message if it can be in a warningGroup, and the group is
+  // Do not display 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)
+    parentWarningGroupMessage &&
+    shouldGroupWarningMessages(parentWarningGroupMessage, messagesState, prefsState) &&
+    !messagesState.messagesUiById.includes(warningGroupMessageId)
   ) {
     return {
       visible: false,
       cause: "closedWarningGroup",
     };
   }
 
+  // Display a message if it is in a warningGroup that is visible. We don't check the
+  // warningGroup visibility if `checkParentWarningGroupVisibility` is false, because
+  // it means we're checking the warningGroup visibility based on the visibility of its
+  // children, which would cause an infinite loop.
+  const parentVisibility = parentWarningGroupMessage && checkParentWarningGroupVisibility
+    ? getMessageVisibility(parentWarningGroupMessage, {
+        messagesState,
+        filtersState,
+        prefsState,
+        uiState,
+        checkGroup,
+        checkParentWarningGroupVisibility,
+    })
+    : null;
+  if (
+    parentVisibility &&
+    parentVisibility.visible &&
+    parentVisibility.cause !== "visibleChild"
+  ) {
+    return {
+      visible: true,
+      cause: "visibleWarningGroup",
+    };
+  }
+
   // Some messages can't be filtered out (e.g. groups).
   // So, always return visible: true for those.
   if (isUnfilterable(message)) {
     return {
       visible: true,
     };
   }
 
@@ -1205,17 +1275,26 @@ function messageExecutionPoint(state, id
   return message.executionPoint || message.lastExecutionPoint.point;
 }
 
 function messageCountSinceLastExecutionPoint(state, id) {
   const message = state.messagesById.get(id);
   return message.lastExecutionPoint ? message.lastExecutionPoint.messageCount : 0;
 }
 
-function maybeSortVisibleMessages(state) {
+/**
+ * Sort state.visibleMessages if needed.
+ *
+ * @param {MessageState} state
+ * @param {Boolean} sortWarningGroupMessage: set to true to sort warningGroup
+ *                                           messages. Default to false, as in some
+ *                                           situations we already take care of putting
+ *                                           the ids at the right position.
+ */
+function maybeSortVisibleMessages(state, sortWarningGroupMessage = false) {
   // When using log points while replaying, messages can be added out of order
   // with respect to how they originally executed. Use the execution point
   // information in the messages to sort visible messages according to how
   // they originally executed. This isn't necessary if we haven't seen any
   // messages with execution points, as either we aren't replaying or haven't
   // seen any messages yet.
   if (state.hasExecutionPoints) {
     state.visibleMessages.sort((a, b) => {
@@ -1230,16 +1309,64 @@ function maybeSortVisibleMessages(state)
       // When messages have the same execution point, they can still be
       // distinguished by the number of messages since the last one which did
       // have an execution point.
       const countA = messageCountSinceLastExecutionPoint(state, a);
       const countB = messageCountSinceLastExecutionPoint(state, b);
       return countA > countB;
     });
   }
+
+  if (state.warningGroupsById.size > 0 && sortWarningGroupMessage) {
+    state.visibleMessages.sort((a, b) => {
+      const messageA = state.messagesById.get(a);
+      const messageB = state.messagesById.get(b);
+
+      const warningGroupIdA = getParentWarningGroupMessageId(messageA);
+      const warningGroupIdB = getParentWarningGroupMessageId(messageB);
+
+      const warningGroupA = state.messagesById.get(warningGroupIdA);
+      const warningGroupB = state.messagesById.get(warningGroupIdB);
+
+      const aFirst = -1;
+      const bFirst = 1;
+
+      // If both messages are in a warningGroup, or if both are not in warningGroups.
+      if (
+        (warningGroupA && warningGroupB) ||
+        (!warningGroupA && !warningGroupB)
+      ) {
+        return messageA.timeStamp < messageB.timeStamp ? aFirst : bFirst;
+      }
+
+      // If `a` is in a warningGroup (and `b` isn't).
+      if (warningGroupA) {
+        // If `b` is the warningGroup of `a`, `a` should be after `b`.
+        if (warningGroupIdA === messageB.id) {
+          return bFirst;
+        }
+        // `b` is a regular message, we place `a` before `b` if `b` came after `a`'s
+        // warningGroup.
+        return messageB.timeStamp > warningGroupA.timeStamp ? aFirst : bFirst;
+      }
+
+      // If `b` is in a warningGroup (and `a` isn't).
+      if (warningGroupB) {
+        // If `a` is the warningGroup of `b`, `a` should be before `b`.
+        if (warningGroupIdB === messageA.id) {
+          return aFirst;
+        }
+        // `a` is a regular message, we place `a` after `b` if `a` came after `b`'s
+        // warningGroup.
+        return messageA.timeStamp > warningGroupB.timeStamp ? bFirst : aFirst;
+      }
+
+      return 0;
+    });
+  }
 }
 
 function getLastMessageId(state) {
   return Array.from(state.messagesById.keys())[state.messagesById.size - 1];
 }
 
 /**
  * Returns if a given type of warning message should be grouped.
--- a/devtools/client/webconsole/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/test/mochitest/browser.ini
@@ -411,13 +411,14 @@ skip-if = verify
 [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_filtering.js]
 [browser_webconsole_warning_groups_outside_console_group.js]
 [browser_webconsole_warning_groups.js]
 [browser_webconsole_websocket.js]
 [browser_webconsole_worker_error.js]
 [browser_webconsole_worker_evaluate.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_warning_groups_filtering.js
@@ -0,0 +1,277 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that filtering the console output when there are warning 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.org/" + TEST_FILE;
+
+const TRACKER_URL = "http://tracking.example.com/";
+const BLOCKED_URL = TRACKER_URL +
+  "browser/devtools/client/webconsole/test/mochitest/test-image.png";
+
+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 CONTENT_BLOCKING_GROUP_LABEL = "Content blocked messages";
+
+  // Enable groupWarning and persist log
+  await pushPref("devtools.webconsole.persistlog", true);
+
+  const hud = await openNewTabAndConsole(TEST_URI);
+
+  info("Log a few content blocking messages and simple ones");
+  let onContentBlockingWarningMessage = waitForMessage(hud, BLOCKED_URL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  await onContentBlockingWarningMessage;
+  await logString(hud, "simple message 1");
+  let onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  const warningGroupMessage1 = (await onContentBlockingWarningGroupMessage).node;
+  await logString(hud, "simple message 2");
+  emitStorageAccessBlockedMessage(hud);
+  await waitForBadgeNumber(warningGroupMessage1, "3");
+  emitStorageAccessBlockedMessage(hud);
+  await waitForBadgeNumber(warningGroupMessage1, "4");
+
+  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;
+
+  // Wait for the navigation message to be displayed.
+  await waitFor(() => findMessage(hud, "Navigated to"));
+  onContentBlockingWarningMessage = waitForMessage(hud, BLOCKED_URL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  await onContentBlockingWarningMessage;
+  await logString(hud, "simple message 3");
+  onContentBlockingWarningGroupMessage =
+    waitForMessage(hud, CONTENT_BLOCKING_GROUP_LABEL, ".warn");
+  emitStorageAccessBlockedMessage(hud);
+  const warningGroupMessage2 = (await onContentBlockingWarningGroupMessage).node;
+  emitStorageAccessBlockedMessage(hud);
+  await waitForBadgeNumber(warningGroupMessage2, "3");
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▶︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 3`,
+  ]);
+
+  info("Filter warnings");
+  await setFilterState(hud, {warn: false});
+  await waitFor(() => !findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `simple message 3`,
+  ]);
+
+  info("Display warning messages again");
+  await setFilterState(hud, {warn: true});
+  await waitFor(() => findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▶︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `▶︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `simple message 3`,
+  ]);
+
+  info("Expand the first warning group");
+  findMessages(hud, CONTENT_BLOCKING_GROUP_LABEL)[0].querySelector(".arrow").click();
+  await waitFor(() => findMessage(hud, BLOCKED_URL));
+
+  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("Filter warnings");
+  await setFilterState(hud, {warn: false});
+  await waitFor(() => !findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `simple message 3`,
+  ]);
+
+  info("Display warning messages again");
+  await setFilterState(hud, {warn: true});
+  await waitFor(() => findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+  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("Filter on warning group text");
+  await setFilterState(hud, {text: CONTENT_BLOCKING_GROUP_LABEL});
+  await waitFor(() => !findMessage(hud, "simple message"));
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `Navigated to`,
+    `▶︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+  ]);
+
+  info("Open the second warning group");
+  findMessages(hud, CONTENT_BLOCKING_GROUP_LABEL)[1].querySelector(".arrow").click();
+  await waitFor(() => findMessage(hud, "?6"));
+
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?1`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?3`,
+    `| ${BLOCKED_URL}?4`,
+    `Navigated to`,
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?5`,
+    `| ${BLOCKED_URL}?6`,
+    `| ${BLOCKED_URL}?7`,
+  ]);
+
+  info("Filter on warning message text from a single warning group");
+  await setFilterState(hud, {text: "/\\?(2|4)/"});
+  await waitFor(() => !findMessage(hud, "?1"));
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?2`,
+    `| ${BLOCKED_URL}?4`,
+    `Navigated to`,
+  ]);
+
+  info("Filter on warning message text from two warning groups");
+  await setFilterState(hud, {text: "/\\?(3|6|7)/"});
+  await waitFor(() => findMessage(hud, "?7"));
+  checkConsoleOutputForWarningGroup(hud, [
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?3`,
+    `Navigated to`,
+    `▼︎⚠ ${CONTENT_BLOCKING_GROUP_LABEL}`,
+    `| ${BLOCKED_URL}?6`,
+    `| ${BLOCKED_URL}?7`,
+  ]);
+
+  info("Clearing text filter");
+  await setFilterState(hud, {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}`,
+    `| ${BLOCKED_URL}?5`,
+    `| ${BLOCKED_URL}?6`,
+    `| ${BLOCKED_URL}?7`,
+    `simple message 3`,
+  ]);
+
+  info("Filter warnings with two opened warning groups");
+  await setFilterState(hud, {warn: false});
+  await waitFor(() => !findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+  checkConsoleOutputForWarningGroup(hud, [
+    `simple message 1`,
+    `simple message 2`,
+    `Navigated to`,
+    `simple message 3`,
+  ]);
+
+  info("Display warning messages again with two opened warning groups");
+  await setFilterState(hud, {warn: true});
+  await waitFor(() => findMessage(hud, CONTENT_BLOCKING_GROUP_LABEL));
+  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`,
+    `| ${BLOCKED_URL}?7`,
+    `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(hud) {
+  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;
+}
+
+function waitForBadgeNumber(message, expectedNumber) {
+  return waitFor(() =>
+    message.querySelector(".warning-group-badge").textContent == expectedNumber);
+}