Bug 1307239 - add context menu to new console frontend;r=nchevobbe draft
authorJulian Descottes <jdescottes@mozilla.com>
Thu, 29 Dec 2016 17:06:59 +0100
changeset 457664 6acadee3a9ba4c77d069b0af6fe93b167fa47d92
parent 457606 6124d2eff2afeebd9fdae6d4742e17cd13b6a613
child 541555 a8dcaa9e213f64906016cc838f932f9222c7c044
push id40856
push userjdescottes@mozilla.com
push dateMon, 09 Jan 2017 15:20:52 +0000
reviewersnchevobbe
bugs1307239
milestone53.0a1
Bug 1307239 - add context menu to new console frontend;r=nchevobbe MozReview-Commit-ID: 6btSC89pBmG
devtools/client/framework/test/shared-head.js
devtools/client/locales/en-US/webconsole.properties
devtools/client/webconsole/new-console-output/components/console-output.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.js
devtools/client/webconsole/new-console-output/components/variables-view-link.js
devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_copy_entire_message.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_copy_link_location.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_open_in_var_view.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_open_url.js
devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_store_as_global.js
devtools/client/webconsole/new-console-output/test/mochitest/head.js
devtools/client/webconsole/new-console-output/utils/context-menu.js
devtools/client/webconsole/new-console-output/utils/moz.build
--- a/devtools/client/framework/test/shared-head.js
+++ b/devtools/client/framework/test/shared-head.js
@@ -459,21 +459,24 @@ function waitForContextMenu(popup, butto
     onHidden && onHidden();
 
     deferred.resolve(popup);
   }
 
   popup.addEventListener("popupshown", onPopupShown);
 
   info("wait for the context menu to open");
-  button.scrollIntoView();
+  synthesizeContextMenuEvent(button);
+  return deferred.promise;
+}
+
+function synthesizeContextMenuEvent(el) {
+  el.scrollIntoView();
   let eventDetails = {type: "contextmenu", button: 2};
-  EventUtils.synthesizeMouse(button, 5, 2, eventDetails,
-                             button.ownerDocument.defaultView);
-  return deferred.promise;
+  EventUtils.synthesizeMouse(el, 5, 2, eventDetails, el.ownerDocument.defaultView);
 }
 
 /**
  * Promise wrapper around SimpleTest.waitForClipboard
  */
 function waitForClipboardPromise(setup, expected) {
   return new Promise((resolve, reject) => {
     SimpleTest.waitForClipboard(expected, setup, resolve, reject);
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -196,8 +196,46 @@ webconsole.find.key=CmdOrCtrl+F
 # LOCALIZATION NOTE (webconsole.close.key)
 # Key shortcut used to close the Browser console (doesn't work in regular web console)
 webconsole.close.key=CmdOrCtrl+W
 
 # LOCALIZATION NOTE (webconsole.clear.key*)
 # Key shortcut used to clear the console output
 webconsole.clear.key=Ctrl+Shift+L
 webconsole.clear.keyOSX=Ctrl+L
+
+
+# LOCALIZATION NOTE (webconsole.menu.copyURL.label)
+# Label used for a context-menu item displayed for network message logs. Clicking on it
+# copies the URL displayed in the message to the clipboard.
+webconsole.menu.copyURL.label=Copy Link Location
+webconsole.menu.copyURL.accesskey=a
+
+# LOCALIZATION NOTE (webconsole.menu.openURL.label)
+# Label used for a context-menu item displayed for network message logs. Clicking on it
+# opens the URL displayed in a new browser tab.
+webconsole.menu.openURL.label=Open URL in New Tab
+webconsole.menu.openURL.accesskey=T
+
+# LOCALIZATION NOTE (webconsole.menu.openInVarView.label)
+# Label used for a context-menu item displayed for object/variable logs. Clicking on it
+# opens the webconsole variable view for the logged variable.
+webconsole.menu.openInVarView.label=Open in Variables View
+webconsole.menu.openInVarView.accesskey=V
+
+# LOCALIZATION NOTE (webconsole.menu.storeAsGlobalVar.label)
+# Label used for a context-menu item displayed for object/variable logs. Clicking on it
+# creates a new global variable pointing to the logged variable.
+webconsole.menu.storeAsGlobalVar.label=Store as global variable
+webconsole.menu.storeAsGlobalVar.accesskey=S
+
+# LOCALIZATION NOTE (webconsole.menu.copy.label)
+# Label used for a context-menu item displayed for any log. Clicking on it will copy the
+# content of the log (or the user selection, if any).
+webconsole.menu.copy.label=Copy
+webconsole.menu.copy.accesskey=C
+
+# LOCALIZATION NOTE (webconsole.menu.selectAll.label)
+# Label used for a context-menu item that will select all the content of the webconsole
+# output.
+webconsole.menu.selectAll.label=Select all
+webconsole.menu.selectAll.accesskey=A
+
--- a/devtools/client/webconsole/new-console-output/components/console-output.js
+++ b/devtools/client/webconsole/new-console-output/components/console-output.js
@@ -53,16 +53,22 @@ const ConsoleOutput = createClass({
   },
 
   componentDidUpdate() {
     if (this.shouldScrollBottom) {
       scrollToBottom(this.outputNode);
     }
   },
 
+  onContextMenu(e) {
+    this.props.serviceContainer.openContextMenu(e);
+    e.stopPropagation();
+    e.preventDefault();
+  },
+
   render() {
     let {
       dispatch,
       autoscroll,
       messages,
       messagesUi,
       messagesTableData,
       serviceContainer,
@@ -94,16 +100,17 @@ const ConsoleOutput = createClass({
 
     if (!timestampsVisible) {
       classList.push("hideTimestamps");
     }
 
     return (
       dom.div({
         className: classList.join(" "),
+        onContextMenu: this.onContextMenu,
         ref: node => {
           this.outputNode = node;
         },
       }, messageNodes
       )
     );
   }
 });
--- 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
@@ -30,23 +30,24 @@ function EvaluationResult(props) {
   const {
     source,
     type,
     level,
     id: messageId,
     exceptionDocURL,
     frame,
     timeStamp,
+    parameters,
   } = message;
 
   let messageBody;
   if (message.messageText) {
     messageBody = message.messageText;
   } else {
-    messageBody = GripMessageBody({grip: message.parameters, serviceContainer});
+    messageBody = GripMessageBody({grip: parameters, serviceContainer});
   }
 
   const topLevelClasses = ["cm-s-mozilla"];
 
   const childProps = {
     source,
     type,
     level,
@@ -54,13 +55,14 @@ function EvaluationResult(props) {
     topLevelClasses,
     messageBody,
     messageId,
     scrollToMessage: props.autoscroll,
     serviceContainer,
     exceptionDocURL,
     frame,
     timeStamp,
+    parameters,
   };
   return Message(childProps);
 }
 
 module.exports = EvaluationResult;
--- 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
@@ -52,13 +52,14 @@ function NetworkEventMessage(props) {
     source,
     type,
     level,
     indent,
     topLevelClasses,
     timeStamp,
     messageBody,
     serviceContainer,
+    request,
   };
   return Message(childProps);
 }
 
 module.exports = NetworkEventMessage;
--- a/devtools/client/webconsole/new-console-output/components/message.js
+++ b/devtools/client/webconsole/new-console-output/components/message.js
@@ -38,21 +38,24 @@ const Message = createClass({
     messageBody: PropTypes.any.isRequired,
     repeat: PropTypes.any,
     frame: PropTypes.any,
     attachment: PropTypes.any,
     stacktrace: PropTypes.any,
     messageId: PropTypes.string,
     scrollToMessage: PropTypes.bool,
     exceptionDocURL: PropTypes.string,
+    parameters: PropTypes.object,
+    request: PropTypes.object,
     serviceContainer: PropTypes.shape({
       emitNewMessage: PropTypes.func.isRequired,
       onViewSourceInDebugger: PropTypes.func.isRequired,
       onViewSourceInScratchpad: PropTypes.func.isRequired,
       onViewSourceInStyleEditor: PropTypes.func.isRequired,
+      openContextMenu: PropTypes.func.isRequired,
       sourceMapService: PropTypes.any,
     }),
   },
 
   getDefaultProps: function () {
     return {
       indent: 0
     };
@@ -72,16 +75,27 @@ const Message = createClass({
     }
   },
 
   onLearnMoreClick: function () {
     let {exceptionDocURL} = this.props;
     this.props.serviceContainer.openLink(exceptionDocURL);
   },
 
+  onContextMenu(e) {
+    let { serviceContainer, source, request } = this.props;
+    let messageInfo = {
+      source,
+      request,
+    };
+    serviceContainer.openContextMenu(e, messageInfo);
+    e.stopPropagation();
+    e.preventDefault();
+  },
+
   render() {
     const {
       messageId,
       open,
       collapsible,
       collapseTitle,
       source,
       type,
@@ -168,16 +182,17 @@ const Message = createClass({
         className: "learn-more-link webconsole-learn-more-link",
         title: exceptionDocURL.split("?")[0],
         onClick: this.onLearnMoreClick,
       }, `[${l10n.getStr("webConsoleMoreInfoLabel")}]`);
     }
 
     return dom.div({
       className: topLevelClasses.join(" "),
+      onContextMenu: this.onContextMenu,
       ref: node => {
         this.messageNode = node;
       }
     },
       timestampEl,
       MessageIndent({indent}),
       icon,
       collapse,
--- a/devtools/client/webconsole/new-console-output/components/variables-view-link.js
+++ b/devtools/client/webconsole/new-console-output/components/variables-view-link.js
@@ -20,15 +20,17 @@ VariablesViewLink.propTypes = {
 };
 
 function VariablesViewLink(props) {
   const { className, object, children } = props;
 
   return (
     dom.a({
       onClick: openVariablesView.bind(null, object),
+      // Context menu can use this actor id information to enable additional menu items.
+      "data-link-actor-id": object.actor,
       className: className || "cm-variable",
       draggable: false,
     }, children)
   );
 }
 
 module.exports = VariablesViewLink;
--- a/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
+++ b/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
@@ -4,26 +4,30 @@
 "use strict";
 
 // React & Redux
 const React = require("devtools/client/shared/vendor/react");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const { Provider } = require("devtools/client/shared/vendor/react-redux");
 
 const actions = require("devtools/client/webconsole/new-console-output/actions/index");
+const { createContextMenu } = require("devtools/client/webconsole/new-console-output/utils/context-menu");
 const { configureStore } = require("devtools/client/webconsole/new-console-output/store");
 
+const EventEmitter = require("devtools/shared/event-emitter");
 const ConsoleOutput = React.createFactory(require("devtools/client/webconsole/new-console-output/components/console-output"));
 const FilterBar = React.createFactory(require("devtools/client/webconsole/new-console-output/components/filter-bar"));
 
 const store = configureStore();
 let queuedActions = [];
 let throttledDispatchTimeout = false;
 
 function NewConsoleOutputWrapper(parentNode, jsterm, toolbox, owner, document) {
+  EventEmitter.decorate(this);
+
   this.parentNode = parentNode;
   this.jsterm = jsterm;
   this.toolbox = toolbox;
   this.owner = owner;
   this.document = document;
 
   this.init = this.init.bind(this);
 }
@@ -58,16 +62,35 @@ NewConsoleOutputWrapper.prototype = {
           frame.url,
           frame.line
         ),
         onViewSourceInStyleEditor: frame => this.toolbox.viewSourceInStyleEditor.call(
           this.toolbox,
           frame.url,
           frame.line
         ),
+        openContextMenu: (e, message) => {
+          let { screenX, screenY, target } = e;
+
+          let messageEl = target.closest(".message");
+          let clipboardText = messageEl ? messageEl.textContent : null;
+
+          // Retrieve closes actor id from the DOM.
+          let actorEl = target.closest("[data-link-actor-id]");
+          let actor = actorEl ? actorEl.dataset.linkActorId : null;
+
+          let menu = createContextMenu(this.jsterm, this.parentNode,
+            { actor, clipboardText, message });
+
+          // Emit the "menu-open" event for testing.
+          menu.once("open", () => this.emit("menu-open"));
+          menu.popup(screenX, screenY, this.toolbox);
+
+          return menu;
+        },
         openNetworkPanel: (requestId) => {
           return this.toolbox.selectTool("netmonitor").then(panel => {
             return panel.panelWin.NetMonitorController.inspectRequest(requestId);
           });
         },
         sourceMapService: this.toolbox ? this.toolbox._sourceMapService : null,
         openLink: url => this.jsterm.hud.owner.openLink.call(this.jsterm.hud.owner, url),
         createElement: nodename => {
@@ -85,16 +108,17 @@ NewConsoleOutputWrapper.prototype = {
         },
       }
     });
     let filterBar = FilterBar({
       serviceContainer: {
         attachRefToHud
       }
     });
+
     let provider = React.createElement(
       Provider,
       { store },
       React.DOM.div(
         {className: "webconsole-output-wrapper"},
         filterBar,
         childComponent
     ));
--- a/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser.ini
@@ -15,16 +15,23 @@ support-files =
   test-location-styleeditor-link-2.css
   test-location-styleeditor-link.html
   test-stacktrace-location-debugger-link.html
   !/devtools/client/framework/test/shared-head.js
 
 [browser_webconsole_batching.js]
 [browser_webconsole_console_group.js]
 [browser_webconsole_console_table.js]
+[browser_webconsole_context_menu_copy_entire_message.js]
+subsuite = clipboard
+[browser_webconsole_context_menu_copy_link_location.js]
+subsuite = clipboard
+[browser_webconsole_context_menu_open_in_var_view.js]
+[browser_webconsole_context_menu_open_url.js]
+[browser_webconsole_context_menu_store_as_global.js]
 [browser_webconsole_filters.js]
 [browser_webconsole_init.js]
 [browser_webconsole_input_focus.js]
 [browser_webconsole_keyboard_accessibility.js]
 [browser_webconsole_location_debugger_link.js]
 [browser_webconsole_location_scratchpad_link.js]
 [browser_webconsole_location_styleeditor_link.js]
 [browser_webconsole_nodes_highlight.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_copy_entire_message.js
@@ -0,0 +1,65 @@
+/* -*- 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";
+
+const TEST_URI = `data:text/html;charset=utf-8,<script>
+  window.logStuff = function () {
+    console.log("simple text message");
+    console.trace();
+  };
+</script>`;
+
+// Test the Copy menu item of the webconsole copies the expected clipboard text for
+// different log messages.
+
+add_task(function* () {
+  let hud = yield openNewTabAndConsole(TEST_URI);
+  hud.jsterm.clearOutput();
+
+  yield ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.wrappedJSObject.logStuff();
+  });
+
+  let messages = [];
+  for (let s of ["simple text message", "console.trace()"]) {
+    messages.push(yield waitFor(() => findMessage(hud, s)));
+  }
+
+  for (let message of messages) {
+    let menuPopup = yield openContextMenu(hud, message);
+    let copyMenuItem = menuPopup.querySelector("#console-menu-copy");
+    ok(copyMenuItem, "copy menu item is enabled");
+
+    yield waitForClipboardPromise(
+      () => copyMenuItem.click(),
+      data => data === message.textContent
+    );
+
+    ok(true, "Clipboard text was found and saved");
+
+    // TODO: The copy menu item & this test should be improved for the new console.
+    // This is tracked by https://bugzilla.mozilla.org/show_bug.cgi?id=1329606 .
+
+    // The rest of this test was copied from the old console frontend. The copy menu item
+    // logic is not on par with the one from the old console yet.
+
+    // let lines = clipboardText.split("\n");
+    // ok(lines.length > 0, "There is at least one newline in the message");
+    // is(lines.pop(), "", "There is a newline at the end");
+    // is(lines.length, result.lines, `There are ${result.lines} lines in the message`);
+
+    // // Test the first line for "timestamp message repeat file:line"
+    // let firstLine = lines.shift();
+    // ok(/^[\d:.]+ .+ \d+ .+:\d+$/.test(firstLine),
+    //   "The message's first line has the right format:\n" + firstLine);
+
+    // // Test the remaining lines (stack trace) for "TABfunctionName sourceURL:line:col"
+    // for (let line of lines) {
+    //   ok(/^\t.+ .+:\d+:\d+$/.test(line),
+    //     "The stack trace line has the right format:\n" + line);
+    // }
+  }
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_copy_link_location.js
@@ -0,0 +1,59 @@
+/* -*- 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/ */
+
+// Test the Copy Link Location menu item of the webconsole is displayed for network
+// messages and copies the expected URL.
+
+"use strict";
+
+const TEST_URI = "http://example.com/browser/devtools/client/webconsole/" +
+  "new-console-output/test/mochitest/test-console.html?_date=" + Date.now();
+const CONTEXT_MENU_ID = "#console-menu-copy-url";
+
+add_task(function* () {
+  // Enable net messages in the console for this test.
+  yield pushPref("devtools.webconsole.filter.net", true);
+
+  let hud = yield openNewTabAndConsole(TEST_URI);
+  hud.jsterm.clearOutput();
+
+  info("Test Copy URL menu item for text log");
+
+  info("Logging a text message in the content window");
+  yield ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.wrappedJSObject.console.log("simple text message");
+  });
+  let message = yield waitFor(() => findMessage(hud, "simple text message"));
+  ok(message, "Text log found in the console");
+
+  info("Open and check the context menu for the logged text message");
+  let menuPopup = yield openContextMenu(hud, message);
+  let copyURLItem = menuPopup.querySelector(CONTEXT_MENU_ID);
+  ok(!copyURLItem, "Copy URL menu item is hidden for a simple text message");
+
+  yield hideContextMenu(hud);
+  hud.jsterm.clearOutput();
+
+  info("Test Copy URL menu item for network log");
+
+  info("Reload the content window to produce a network log");
+  yield ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.wrappedJSObject.location.reload();
+  });
+
+  message = yield waitFor(() => findMessage(hud, "test-console.html"));
+  ok(message, "Network log found in the console");
+
+  info("Open and check the context menu for the logged network message");
+  menuPopup = yield openContextMenu(hud, message);
+  copyURLItem = menuPopup.querySelector(CONTEXT_MENU_ID);
+  ok(copyURLItem, "Copy url menu item is available in context menu");
+
+  info("Click on Copy URL menu item and wait for clipboard to be updated");
+  yield waitForClipboardPromise(() => copyURLItem.click(), TEST_URI);
+  ok(true, "Expected text was copied to the clipboard.");
+
+  yield hideContextMenu(hud);
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_open_in_var_view.js
@@ -0,0 +1,45 @@
+/* -*- 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/ */
+
+// Test the Open in Variables View menu item of the webconsole is enabled only when
+// clicking on messages that can be opened in the variables view.
+
+"use strict";
+
+const TEST_URI = `data:text/html;charset=utf-8,<script>
+  console.log("foo");
+  console.log("foo", window);
+</script>`;
+
+add_task(function* () {
+  let hud = yield openNewTabAndConsole(TEST_URI);
+
+  let [msgWithText, msgWithObj] = yield waitFor(() => findMessages(hud, "foo"));
+  ok(msgWithText && msgWithObj, "Two messages should have appeared");
+
+  let text = msgWithText.querySelector(".objectBox-string");
+  let objInMsgWithObj = msgWithObj.querySelector(".cm-variable");
+  let textInMsgWithObj = msgWithObj.querySelector(".objectBox-string");
+
+  info("Check open in variables view is disabled for text only messages");
+  let menuPopup = yield openContextMenu(hud, text);
+  let openMenuItem = menuPopup.querySelector("#console-menu-open");
+  ok(openMenuItem.disabled, "open in variables view is disabled for text message");
+  yield hideContextMenu(hud);
+
+  info("Check open in variables view is enabled for objects in complex messages");
+  menuPopup = yield openContextMenu(hud, objInMsgWithObj);
+  openMenuItem = menuPopup.querySelector("#console-menu-open");
+  ok(!openMenuItem.disabled,
+    "open in variables view is enabled for object in complex message");
+  yield hideContextMenu(hud);
+
+  info("Check open in variables view is disabled for text in complex messages");
+  menuPopup = yield openContextMenu(hud, textInMsgWithObj);
+  openMenuItem = menuPopup.querySelector("#console-menu-open");
+  ok(openMenuItem.disabled,
+    "open in variables view is disabled for text in complex message");
+  yield hideContextMenu(hud);
+});
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_open_url.js
@@ -0,0 +1,80 @@
+/* -*- 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/ */
+
+// Test that the Open URL in new Tab menu item is displayed for network logs and works as
+// expected.
+
+"use strict";
+
+const TEST_URI = "http://example.com/browser/devtools/client/webconsole/" +
+  "new-console-output/test/mochitest/test-console.html";
+
+add_task(function* () {
+  // Enable net messages in the console for this test.
+  yield pushPref("devtools.webconsole.filter.net", true);
+
+  let hud = yield openNewTabAndConsole(TEST_URI);
+  hud.jsterm.clearOutput();
+
+  info("Test Open URL menu item for text log");
+
+  info("Logging a text message in the content window");
+  yield ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.wrappedJSObject.console.log("simple text message");
+  });
+  let message = yield waitFor(() => findMessage(hud, "simple text message"));
+  ok(message, "Text log found in the console");
+
+  info("Open and check the context menu for the logged text message");
+  let menuPopup = yield openContextMenu(hud, message);
+  let openUrlItem = menuPopup.querySelector("#console-menu-open-url");
+  ok(!openUrlItem, "Open URL menu item is not available");
+
+  yield hideContextMenu(hud);
+  hud.jsterm.clearOutput();
+
+  info("Test Open URL menu item for network log");
+
+  info("Reload the content window to produce a network log");
+  yield ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
+    content.wrappedJSObject.location.reload();
+  });
+  message = yield waitFor(() => findMessage(hud, "test-console.html"));
+  ok(message, "Network log found in the console");
+
+  info("Open and check the context menu for the logged network message");
+  menuPopup = yield openContextMenu(hud, message);
+  openUrlItem = menuPopup.querySelector("#console-menu-open-url");
+  ok(openUrlItem, "Open URL menu item is available");
+
+  let currentTab = gBrowser.selectedTab;
+  let tabLoaded = listenToTabLoad();
+  info("Click on Open URL menu item and wait for new tab to open");
+  openUrlItem.click();
+  yield hideContextMenu(hud);
+  let newTab = yield tabLoaded;
+  let newTabHref = newTab.linkedBrowser._contentWindow.location.href;
+  is(newTabHref, TEST_URI, "Tab was opened with the expected URL");
+
+  info("Remove the new tab and select the previous tab back");
+  gBrowser.removeTab(newTab);
+  gBrowser.selectedTab = currentTab;
+});
+
+/**
+ * Simple helper to wrap a tab load listener in a promise.
+ */
+function listenToTabLoad() {
+  return new Promise((resolve) => {
+    gBrowser.tabContainer.addEventListener("TabOpen", function onTabOpen(evt) {
+      gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen, true);
+      let newTab = evt.target;
+      newTab.linkedBrowser.addEventListener("load", function onTabLoad() {
+        newTab.linkedBrowser.removeEventListener("load", onTabLoad, true);
+        resolve(newTab);
+      }, true);
+    }, true);
+  });
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/browser_webconsole_context_menu_store_as_global.js
@@ -0,0 +1,91 @@
+/* -*- 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/ */
+
+// Test the "Store as global variable" menu item of the webconsole is enabled only when
+// clicking on messages that are associated with an object actor.
+
+"use strict";
+
+const TEST_URI = `data:text/html;charset=utf-8,<script>
+  window.bar = { baz: 1 };
+  console.log("foo");
+  console.log("foo", window.bar);
+  console.log(["foo", window.bar, 2]);
+</script>`;
+
+add_task(function* () {
+  let hud = yield openNewTabAndConsole(TEST_URI);
+
+  let [msgWithText, msgWithObj, msgNested] =
+    yield waitFor(() => findMessages(hud, "foo"));
+  ok(msgWithText && msgWithObj && msgNested, "Three messages should have appeared");
+
+  let text = msgWithText.querySelector(".objectBox-string");
+  let objInMsgWithObj = msgWithObj.querySelector(".cm-variable");
+  let textInMsgWithObj = msgWithObj.querySelector(".objectBox-string");
+
+  // The third message has an object nested in an array, the array is therefore the top
+  // object, the object is the nested object.
+  let topObjInMsg = msgNested.querySelector(".objectBox-array > .cm-variable");
+  let nestedObjInMsg = msgNested.querySelector(".objectBox-object > .cm-variable");
+
+  info("Check store as global variable is disabled for text only messages");
+  let menuPopup = yield openContextMenu(hud, text);
+  let storeMenuItem = menuPopup.querySelector("#console-menu-store");
+  ok(storeMenuItem.disabled, "store as global variable is disabled for text message");
+  yield hideContextMenu(hud);
+
+  info("Check store as global variable is disabled for text in complex messages");
+  menuPopup = yield openContextMenu(hud, textInMsgWithObj);
+  storeMenuItem = menuPopup.querySelector("#console-menu-store");
+  ok(storeMenuItem.disabled,
+    "store as global variable is disabled for text in complex message");
+  yield hideContextMenu(hud);
+
+  info("Check store as global variable is enabled for objects in complex messages");
+  yield storeAsVariable(hud, objInMsgWithObj);
+
+  is(hud.jsterm.getInputValue(), "temp0", "Input was set");
+
+  let executedResult = yield hud.jsterm.execute();
+  ok(executedResult.textContent.includes("{ baz: 1 }"),
+     "Correct variable assigned into console");
+
+  info("Check store as global variable is enabled for top object in nested messages");
+  yield storeAsVariable(hud, topObjInMsg);
+
+  is(hud.jsterm.getInputValue(), "temp1", "Input was set");
+
+  executedResult = yield hud.jsterm.execute();
+  ok(executedResult.textContent.includes("[ \"foo\", Object, 2 ]"),
+     "Correct variable assigned into console " + executedResult.textContent);
+
+  info("Check store as global variable is enabled for nested object in nested messages");
+  yield storeAsVariable(hud, nestedObjInMsg);
+
+  is(hud.jsterm.getInputValue(), "temp2", "Input was set");
+
+  executedResult = yield hud.jsterm.execute();
+  ok(executedResult.textContent.includes("{ baz: 1 }"),
+     "Correct variable assigned into console " + executedResult.textContent);
+});
+
+function* storeAsVariable(hud, element) {
+  info("Check store as global variable is enabled");
+  let menuPopup = yield openContextMenu(hud, element);
+  let storeMenuItem = menuPopup.querySelector("#console-menu-store");
+  ok(!storeMenuItem.disabled,
+    "store as global variable is enabled for object in complex message");
+
+  info("Click on store as global variable");
+  let onceInputSet = hud.jsterm.once("set-input-value");
+  storeMenuItem.click();
+
+  info("Wait for console input to be updated with the temp variable");
+  yield onceInputSet;
+
+  info("Wait for context menu to be hidden");
+  yield hideContextMenu(hud);
+}
--- a/devtools/client/webconsole/new-console-output/test/mochitest/head.js
+++ b/devtools/client/webconsole/new-console-output/test/mochitest/head.js
@@ -1,14 +1,15 @@
 /* -*- 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/ */
 /* import-globals-from ../../../../framework/test/shared-head.js */
-/* exported WCUL10n, openNewTabAndConsole, waitForMessages, waitFor, findMessage */
+/* exported WCUL10n, openNewTabAndConsole, waitForMessages, waitFor, findMessage,
+   openContextMenu, hideContextMenu */
 
 "use strict";
 
 // shared-head.js handles imports, constants, and utility functions
 // Load the shared-head file first.
 Services.scriptloader.loadSubScript(
   "chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js",
   this);
@@ -135,8 +136,44 @@ function findMessage(hud, text, selector
 function findMessages(hud, text, selector = ".message") {
   const messages = hud.ui.experimentalOutputNode.querySelectorAll(selector);
   const elements = Array.prototype.filter.call(
     messages,
     (el) => el.textContent.includes(text)
   );
   return elements;
 }
+
+/**
+ * Simulate a context menu event on the provided element, and wait for the console context
+ * menu to open. Returns a promise that resolves the menu popup element.
+ *
+ * @param object hud
+ *        The web console.
+ * @param element element
+ *        The dom element on which the context menu event should be synthesized.
+ * @return promise
+ */
+function* openContextMenu(hud, element) {
+  let onConsoleMenuOpened = hud.ui.newConsoleOutput.once("menu-open");
+  synthesizeContextMenuEvent(element);
+  yield onConsoleMenuOpened;
+  return hud.ui.newConsoleOutput.toolbox.doc.getElementById("webconsole-menu");
+}
+
+/**
+ * Hide the webconsole context menu popup. Returns a promise that will resolve when the
+ * context menu popup is hidden or immediately if the popup can't be found.
+ *
+ * @param object hud
+ *        The web console.
+ * @return promise
+ */
+function hideContextMenu(hud) {
+  let popup = hud.ui.newConsoleOutput.toolbox.doc.getElementById("webconsole-menu");
+  if (!popup) {
+    return Promise.resolve();
+  }
+
+  let onPopupHidden = once(popup, "popuphidden");
+  popup.hidePopup();
+  return onPopupHidden;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/new-console-output/utils/context-menu.js
@@ -0,0 +1,143 @@
+/* -*- 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";
+
+const Services = require("Services");
+const {gDevTools} = require("devtools/client/framework/devtools");
+
+const Menu = require("devtools/client/framework/menu");
+const MenuItem = require("devtools/client/framework/menu-item");
+
+const { MESSAGE_SOURCE } = require("devtools/client/webconsole/new-console-output/constants");
+
+const {openVariablesView} = require("devtools/client/webconsole/new-console-output/utils/variables-view");
+const clipboardHelper = require("devtools/shared/platform/clipboard");
+const { l10n } = require("devtools/client/webconsole/new-console-output/utils/messages");
+
+/**
+ * Create a Menu instance for the webconsole.
+ *
+ * @param {Object} jsterm
+ *        The JSTerm instance used by the webconsole.
+ * @param {Element} parentNode
+ *        The container of the new console frontend output wrapper.
+ * @param {Object} options
+ *        - {String} actor (optional) actor id to use for context menu actions
+ *        - {String} clipboardText (optional) text to "Copy" if no selection is available
+ *        - {Object} message (optional) message object containing metadata such as:
+ *          - {String} source
+ *          - {String} request
+ */
+function createContextMenu(jsterm, parentNode, { actor, clipboardText, message }) {
+  let win = parentNode.ownerDocument.defaultView;
+  let selection = win.getSelection();
+
+  let { source, request } = message || {};
+
+  let menu = new Menu({
+    id: "webconsole-menu"
+  });
+
+  // Copy URL for a network request.
+  menu.append(new MenuItem({
+    id: "console-menu-copy-url",
+    label: l10n.getStr("webconsole.menu.copyURL.label"),
+    accesskey: l10n.getStr("webconsole.menu.copyURL.accesskey"),
+    visible: source === MESSAGE_SOURCE.NETWORK,
+    click: () => {
+      if (!request) {
+        return;
+      }
+      clipboardHelper.copyString(request.url);
+    },
+  }));
+
+  // Open URL in a new tab for a network request.
+  menu.append(new MenuItem({
+    id: "console-menu-open-url",
+    label: l10n.getStr("webconsole.menu.openURL.label"),
+    accesskey: l10n.getStr("webconsole.menu.openURL.accesskey"),
+    visible: source === MESSAGE_SOURCE.NETWORK,
+    click: () => {
+      if (!request) {
+        return;
+      }
+      let mainWindow = Services.wm.getMostRecentWindow(gDevTools.chromeWindowType);
+      mainWindow.openUILinkIn(request.url, "tab");
+    },
+  }));
+
+  // Open in variables view.
+  menu.append(new MenuItem({
+    id: "console-menu-open",
+    label: l10n.getStr("webconsole.menu.openInVarView.label"),
+    accesskey: l10n.getStr("webconsole.menu.openInVarView.accesskey"),
+    disabled: !actor,
+    click: () => {
+      openVariablesView(actor);
+    },
+  }));
+
+  // Store as global variable.
+  menu.append(new MenuItem({
+    id: "console-menu-store",
+    label: l10n.getStr("webconsole.menu.storeAsGlobalVar.label"),
+    accesskey: l10n.getStr("webconsole.menu.storeAsGlobalVar.accesskey"),
+    disabled: !actor,
+    click: () => {
+      let evalString = `{ let i = 0;
+        while (this.hasOwnProperty("temp" + i) && i < 1000) {
+          i++;
+        }
+        this["temp" + i] = _self;
+        "temp" + i;
+      }`;
+      let options = {
+        selectedObjectActor: actor,
+      };
+
+      jsterm.requestEvaluation(evalString, options).then((res) => {
+        jsterm.focus();
+        jsterm.setInputValue(res.result);
+      });
+    },
+  }));
+
+  // Copy message or grip.
+  menu.append(new MenuItem({
+    id: "console-menu-copy",
+    label: l10n.getStr("webconsole.menu.copy.label"),
+    accesskey: l10n.getStr("webconsole.menu.copy.accesskey"),
+    // Disabled if there is no selection and no message element available to copy.
+    disabled: selection.isCollapsed && !clipboardText,
+    click: () => {
+      if (selection.isCollapsed) {
+        // If the selection is empty/collapsed, copy the text content of the
+        // message for which the context menu was opened.
+        clipboardHelper.copyString(clipboardText);
+      } else {
+        clipboardHelper.copyString(selection.toString());
+      }
+    },
+  }));
+
+  // Select all.
+  menu.append(new MenuItem({
+    id: "console-menu-select",
+    label: l10n.getStr("webconsole.menu.selectAll.label"),
+    accesskey: l10n.getStr("webconsole.menu.selectAll.accesskey"),
+    disabled: false,
+    click: () => {
+      let webconsoleOutput = parentNode.querySelector(".webconsole-output");
+      selection.selectAllChildren(webconsoleOutput);
+    },
+  }));
+
+  return menu;
+}
+
+exports.createContextMenu = createContextMenu;
--- a/devtools/client/webconsole/new-console-output/utils/moz.build
+++ b/devtools/client/webconsole/new-console-output/utils/moz.build
@@ -1,10 +1,11 @@
 # vim: set filetype=python:
 # 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/.
 
 DevToolsModules(
+    'context-menu.js',
     'id-generator.js',
     'messages.js',
     'variables-view.js',
 )