Bug 1517728 - Export Console content to file. .
authorJefry Lagrange <jefry.reyes@gmail.com>
Fri, 28 Jun 2019 08:28:53 +0000
changeset 543324 0883abbe6a6fdb79fd6699ddbf48247168147bf2
parent 543323 097eedc2b14f7adcc8d4d1cbd151ff5135d0a112
child 543325 c633a515314d0d1451322d4c6bfda0031cadb459
push id2131
push userffxbld-merge
push dateMon, 26 Aug 2019 18:30:20 +0000
treeherdermozilla-release@b19ffb3ca153 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1517728
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 1517728 - Export Console content to file. . Add a context menu entry to export the console output to a file. We group the 2 export entries into a submenu. Differential Revision: https://phabricator.services.mozilla.com/D22158
devtools/client/locales/en-US/webconsole.properties
devtools/client/webconsole/test/mochitest/browser.ini
devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
devtools/client/webconsole/test/mochitest/browser_webconsole_context_menu_export_console_output.js
devtools/client/webconsole/test/mochitest/browser_webconsole_context_menu_export_console_output_clipboard.js
devtools/client/webconsole/utils/context-menu.js
devtools/shared/DevToolsUtils.js
devtools/shared/tests/unit/test_console_save-file.js
devtools/shared/tests/unit/xpcshell.ini
--- a/devtools/client/locales/en-US/webconsole.properties
+++ b/devtools/client/locales/en-US/webconsole.properties
@@ -198,20 +198,31 @@ webconsole.menu.selectAll.label=Select a
 webconsole.menu.selectAll.accesskey=A
 
 # LOCALIZATION NOTE (webconsole.menu.openInSidebar.label)
 # Label used for a context-menu item displayed for object/variable logs. Clicking on it
 # opens the webconsole sidebar for the logged variable.
 webconsole.menu.openInSidebar.label=Open in sidebar
 webconsole.menu.openInSidebar.accesskey=V
 
+# LOCALIZATION NOTE (webconsole.menu.exportSubmenu.label)
+# Label used for a context-menu item displayed on the output. Clicking on it
+# opens a submenu where the user can select how to export messages.
+webconsole.menu.exportSubmenu.label=Export visible messages to
+
 # LOCALIZATION NOTE (webconsole.menu.exportClipboard.label)
 # Label used for a context-menu item displayed on the output. Clicking on it
 # copies the entire output of the console to the clipboard.
-webconsole.menu.exportClipboard.label=Export visible messages to clipboard
+webconsole.menu.exportSubmenu.exportCliboard.label=Clipboard
+
+# LOCALIZATION NOTE (webconsole.menu.exportFile.label)
+# Label used for a context-menu item displayed on the output. Clicking on it
+# opens a file picker to allow the user save a file containing
+# the output of the console.
+webconsole.menu.exportSubmenu.exportFile.label=File
 
 # LOCALIZATION NOTE (webconsole.menu.timeWarp.label)
 # Label used for a context-menu item displayed for any log. Clicking on it will
 # jump to the execution point where the log item was generated.
 webconsole.menu.timeWarp.label=Jump here
 
 # LOCALIZATION NOTE (webconsole.jumpButton.tooltip)
 # Label used for the tooltip on the "jump" button in the console. It's displayed when
--- a/devtools/client/webconsole/test/mochitest/browser.ini
+++ b/devtools/client/webconsole/test/mochitest/browser.ini
@@ -268,17 +268,17 @@ skip-if = true # Bug 1405250
 [browser_webconsole_console_group_open_no_scroll.js]
 [browser_webconsole_console_group.js]
 [browser_webconsole_console_logging_workers_api.js]
 skip-if = e10s # SharedWorkers console events are not received on the current process because they could run on any process.
 [browser_webconsole_console_table.js]
 [browser_webconsole_console_timeStamp.js]
 [browser_webconsole_console_trace_distinct.js]
 [browser_webconsole_console_trace_duplicates.js]
-[browser_webconsole_context_menu_export_console_output_clipboard.js]
+[browser_webconsole_context_menu_export_console_output.js]
 tags = clipboard
 [browser_webconsole_context_menu_copy_entire_message.js]
 tags = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_webconsole_context_menu_copy_link_location.js]
 tags = clipboard
 skip-if = (os == 'linux' && bits == 32 && debug) || (os == 'linux') # bug 1328915, disable linux32 debug devtools for timeouts, bug 1473120
 [browser_webconsole_context_menu_copy_message_with_framework_stacktrace.js]
--- a/devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
+++ b/devtools/client/webconsole/test/mochitest/browser_console_context_menu_entries.js
@@ -41,16 +41,17 @@ async function performTests() {
   let expectedContextMenu = addPrefBasedEntries([
     "#console-menu-copy-url (a)",
     "#console-menu-open-url (T)",
     "#console-menu-store (S) [disabled]",
     "#console-menu-copy (C)",
     "#console-menu-copy-object (o) [disabled]",
     "#console-menu-select (A)",
     "#console-menu-export-clipboard ()",
+    "#console-menu-export-file ()",
   ]);
   is(getSimplifiedContextMenu(menuPopup).join("\n"), expectedContextMenu.join("\n"),
     "The context menu has the expected entries for a network message");
 
   info("Logging a text message in the content window");
   const onLogMessage = waitForMessage(hud, "simple text message");
   ContentTask.spawn(gBrowser.selectedBrowser, null, () => {
     content.wrappedJSObject.console.log("simple text message");
@@ -61,16 +62,17 @@ async function performTests() {
   ok(menuPopup, "The context menu is displayed on a log message");
 
   expectedContextMenu = addPrefBasedEntries([
     "#console-menu-store (S) [disabled]",
     "#console-menu-copy (C)",
     "#console-menu-copy-object (o) [disabled]",
     "#console-menu-select (A)",
     "#console-menu-export-clipboard ()",
+    "#console-menu-export-file ()",
   ]);
   is(getSimplifiedContextMenu(menuPopup).join("\n"), expectedContextMenu.join("\n"),
     "The context menu has the expected entries for a simple log message");
 
   menuPopup = await openContextMenu(hud, hud.jsterm.node || hud.jsterm.inputNode);
 
   let actualEntries = getL10NContextMenu(menuPopup);
   is(actualEntries.length, 6, "The context menu has the right number of entries.");
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_webconsole_context_menu_export_console_output.js
@@ -0,0 +1,186 @@
+/* -*- 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 httpServer = createTestHTTPServer();
+httpServer.registerPathHandler(`/`, function(request, response) {
+  response.setStatusLine(request.httpVersion, 200, "OK");
+  response.write(`
+    <html>
+      <head>
+        <meta charset="utf-8">
+        <script type="text/javascript" src="test.js"></script>
+      </head>
+      <body>Test "Export All" context menu entry</body>
+    </html>`);
+});
+
+httpServer.registerPathHandler("/test.js", function(request, response) {
+  response.setHeader("Content-Type", "application/javascript");
+  response.write(`
+    window.logStuff = function() {
+      function wrapper() {
+        console.log("hello");
+        console.log("myObject:", {a: 1}, "myArray:", ["b", "c"]);
+        console.log(new Error("error object"));
+        console.trace("myConsoleTrace");
+        console.info("world", "!");
+      }
+      wrapper();
+    };
+  `);
+});
+
+const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/`;
+
+const { MockFilePicker } = SpecialPowers;
+MockFilePicker.init(window);
+MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+var {Cu} = require("chrome");
+var FileUtils = Cu.import("resource://gre/modules/FileUtils.jsm").FileUtils;
+
+// Test the export visible messages to clipboard of the webconsole copies the expected
+// clipboard text for different log messages to find if everything is copied to clipboard.
+
+add_task(async function testExportToClipboard() {
+  const hud = await openNewTabAndConsole(TEST_URI);
+  hud.ui.clearOutput();
+
+  info("Call the log function defined in the test page");
+  await ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
+    content.wrappedJSObject.logStuff();
+  });
+
+  info("Test export to clipboard ");
+  // Let's wait until we have all the logged messages.
+  await waitFor(() => findMessages(hud, "").length === 5);
+  // And also until the stacktraces are rendered (there should be 2)
+  await waitFor(() => hud.ui.outputNode.querySelectorAll(".frames").length === 2);
+
+  const message = findMessage(hud, "hello");
+  const clipboardText = await exportAllToClipboard(hud, message);
+  ok(true, "Clipboard text was found and saved");
+
+  checkExportedText(clipboardText);
+});
+
+add_task(async function testExportToFile() {
+  const hud = await openNewTabAndConsole(TEST_URI);
+  hud.ui.clearOutput();
+
+  info("Call the log function defined in the test page");
+  await ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
+    content.wrappedJSObject.logStuff();
+  });
+
+  info("Test export to clipboard ");
+  // Let's wait until we have all the logged messages.
+  await waitFor(() => findMessages(hud, "").length === 5);
+  // And also until the stacktraces are rendered (there should be 2)
+  await waitFor(() => hud.ui.outputNode.querySelectorAll(".frames").length === 2);
+
+  const message = findMessage(hud, "hello");
+  const text = await exportAllToFile(hud, message);
+  checkExportedText(text);
+});
+
+function checkExportedText(text) {
+  // Here we should have:
+  //   -------------------------------------------------------------------
+  //   hello test.js:4:17
+  //   -------------------------------------------------------------------
+  //   myObject:
+  //   Object { a: 1 }
+  //    myArray:
+  //   Array [ "b", "c"]
+  //   test.js:5:17
+  //   -------------------------------------------------------------------
+  //   Error: "error object":
+  //       wrapper test.js:5
+  //       logStuff test.js:17
+  //   test.js:6:17
+  //   -------------------------------------------------------------------
+  //   console.trace() myConsoleTrace test.js:7:9
+  //       wrapper test.js:7
+  //       logStuff test.js:17
+  //   -------------------------------------------------------------------
+  //   world ! test.js:8:17
+  //   -------------------------------------------------------------------
+  info("Check if all messages where exported as expected");
+  const lines = text.split("\n").map(line => line.replace(/\r$/, ""));
+
+  is(lines.length, 15, "There's 15 lines of text");
+  is(lines[lines.length - 1], "", "Last line is empty");
+
+  info("Check simple text message");
+  is(lines[0], "hello test.js:4:17", "Simple log has expected text");
+
+  info("Check multiple logged items message");
+  is(lines[1], `myObject: `);
+  is(lines[2], `Object { a: 1 }`);
+  is(lines[3], ` myArray: `);
+  is(lines[4], `Array [ "b", "c" ]`);
+  is(lines[5], `test.js:5:17`);
+
+  info("Check logged error object");
+  is(lines[6], `Error: "error object"`);
+  is(lines[7], `    wrapper ${TEST_URI}test.js:6`);
+  is(lines[8], `    logStuff ${TEST_URI}test.js:10`);
+  is(lines[9], `test.js:6:17`);
+
+  info("Check console.trace message");
+  is(lines[10], `console.trace() myConsoleTrace test.js:7:17`);
+  is(lines[11], `    wrapper ${TEST_URI}test.js:7`);
+  is(lines[12], `    logStuff ${TEST_URI}test.js:10`);
+
+  info("Check console.info message");
+  is(lines[13], `world ! test.js:8:17`);
+}
+
+async function exportAllToFile(hud, message) {
+  const menuPopup = await openContextMenuExportSubMenu(hud, message);
+  const exportFile = menuPopup.querySelector("#console-menu-export-file");
+  ok(exportFile, "copy menu item is enabled");
+
+  const nsiFile = FileUtils.getFile("TmpD", [`export_console_${Date.now()}.log`]);
+  MockFilePicker.setFiles([nsiFile]);
+  exportFile.click();
+
+  // The file may not be ready yet.
+  await waitFor(() => OS.File.exists(nsiFile.path));
+  const buffer = await OS.File.read(nsiFile.path);
+  return new TextDecoder().decode(buffer);
+}
+
+/**
+ * Simple helper method to open the context menu on a given message, and click on the
+ * export visible messages to clipboard.
+ */
+async function exportAllToClipboard(hud, message) {
+  const menuPopup = await openContextMenuExportSubMenu(hud, message);
+  const exportClipboard = menuPopup.querySelector("#console-menu-export-clipboard");
+  ok(exportClipboard, "copy menu item is enabled");
+
+  let clipboardText;
+  await waitForClipboardPromise(
+    () => exportClipboard.click(),
+    data => {
+      clipboardText = data;
+      return data;
+    }
+  );
+  return clipboardText;
+}
+
+async function openContextMenuExportSubMenu(hud, message) {
+  const menuPopup = await openContextMenu(hud, message);
+  const exportMenu = menuPopup.querySelector("#console-menu-export");
+
+  const view = exportMenu.ownerDocument.defaultView;
+  EventUtils.synthesizeMouseAtCenter(exportMenu, {type: "mousemove"}, view);
+  return menuPopup;
+}
deleted file mode 100644
--- a/devtools/client/webconsole/test/mochitest/browser_webconsole_context_menu_export_console_output_clipboard.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/* -*- 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 httpServer = createTestHTTPServer();
-httpServer.registerPathHandler(`/`, function(request, response) {
-  response.setStatusLine(request.httpVersion, 200, "OK");
-  response.write(`
-    <html>
-      <head>
-        <meta charset="utf-8">
-        <script type="text/javascript" src="test.js"></script>
-      </head>
-      <body>Test "Export All" context menu entry</body>
-    </html>`);
-});
-
-httpServer.registerPathHandler("/test.js", function(request, response) {
-  response.setHeader("Content-Type", "application/javascript");
-  response.write(`
-    window.logStuff = function() {
-      function wrapper() {
-        console.log("hello");
-        console.log("myObject:", {a: 1}, "myArray:", ["b", "c"]);
-        console.log(new Error("error object"));
-        console.trace("myConsoleTrace");
-        console.info("world", "!");
-      }
-      wrapper();
-    };
-  `);
-});
-
-const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/`;
-
-// Test the export visible messages to clipboard of the webconsole copies the expected
-// clipboard text for different log messages to find if everything is copied to clipboard.
-
-add_task(async function() {
-  const hud = await openNewTabAndConsole(TEST_URI);
-  hud.ui.clearOutput();
-
-  info("Call the log function defined in the test page");
-  await ContentTask.spawn(gBrowser.selectedBrowser, null, function() {
-    content.wrappedJSObject.logStuff();
-  });
-
-  info("Test export to clipboard ");
-  // Let's wait until we have all the logged messages.
-  await waitFor(() => findMessages(hud, "").length === 5);
-  // And also until the stacktraces are rendered (there should be 2)
-  await waitFor(() => hud.ui.outputNode.querySelectorAll(".frames").length === 2);
-
-  const message = findMessage(hud, "hello");
-  const clipboardText = await exportAllToClipboard(hud, message);
-  ok(true, "Clipboard text was found and saved");
-
-// Here we should have:
-//   -------------------------------------------------------------------
-//   hello test.js:4:17
-//   -------------------------------------------------------------------
-//   myObject:
-//   Object { a: 1 }
-//    myArray:
-//   Array [ "b", "c"]
-//   test.js:5:17
-//   -------------------------------------------------------------------
-//   Error: "error object":
-//       wrapper test.js:5
-//       logStuff test.js:17
-//   test.js:6:17
-//   -------------------------------------------------------------------
-//   console.trace() myConsoleTrace test.js:7:9
-//       wrapper test.js:7
-//       logStuff test.js:17
-//   -------------------------------------------------------------------
-//   world ! test.js:8:17
-//   -------------------------------------------------------------------
-
-  info("Check if all messages where copied to clipboard");
-  const clipboardLines = clipboardText.split("\n");
-  is(clipboardLines.length, 15, "There's 15 lines of text");
-  is(clipboardLines[clipboardLines.length - 1], "", "Last line is empty");
-
-  info("Check simple text message");
-  is(clipboardLines[0], "hello test.js:4:17", "Simple log has expected text");
-
-  info("Check multiple logged items message");
-  is(clipboardLines[1], `myObject: `);
-  is(clipboardLines[2], `Object { a: 1 }`);
-  is(clipboardLines[3], ` myArray: `);
-  is(clipboardLines[4], `Array [ "b", "c" ]`);
-  is(clipboardLines[5], `test.js:5:17`);
-
-  info("Check logged error object");
-  is(clipboardLines[6], `Error: "error object"`);
-  is(clipboardLines[7], `    wrapper ${TEST_URI}test.js:6`);
-  is(clipboardLines[8], `    logStuff ${TEST_URI}test.js:10`);
-  is(clipboardLines[9], `test.js:6:17`);
-
-  info("Check console.trace message");
-  is(clipboardLines[10], `console.trace() myConsoleTrace test.js:7:17`);
-  is(clipboardLines[11], `    wrapper ${TEST_URI}test.js:7`);
-  is(clipboardLines[12], `    logStuff ${TEST_URI}test.js:10`);
-
-  info("Check console.info message");
-  is(clipboardLines[13], `world ! test.js:8:17`);
-});
-
-/**
- * Simple helper method to open the context menu on a given message, and click on the
- * export visible messages to clipboard.
- */
-async function exportAllToClipboard(hud, message) {
-  const menuPopup = await openContextMenu(hud, message);
-  const exportClipboard = menuPopup.querySelector("#console-menu-export-clipboard");
-  ok(exportClipboard, "copy menu item is enabled");
-
-  let clipboardText;
-  await waitForClipboardPromise(
-    () => exportClipboard.click(),
-    data => {
-      clipboardText = data;
-      return data;
-    }
-  );
-  return clipboardText;
-}
--- a/devtools/client/webconsole/utils/context-menu.js
+++ b/devtools/client/webconsole/utils/context-menu.js
@@ -9,16 +9,18 @@
 const Menu = require("devtools/client/framework/menu");
 const MenuItem = require("devtools/client/framework/menu-item");
 
 const { MESSAGE_SOURCE } = require("devtools/client/webconsole/constants");
 
 const clipboardHelper = require("devtools/shared/platform/clipboard");
 const { l10n } = require("devtools/client/webconsole/utils/messages");
 
+loader.lazyRequireGetter(this, "showSaveFileDialog", "devtools/shared/DevToolsUtils", true);
+loader.lazyRequireGetter(this, "saveFileStream", "devtools/shared/DevToolsUtils", true);
 loader.lazyRequireGetter(this, "openContentLink", "devtools/client/shared/link", true);
 loader.lazyRequireGetter(this, "getElementText", "devtools/client/webconsole/utils/clipboard", true);
 
 /**
  * Create a Menu instance for the webconsole.
  *
  * @param {WebConsoleUI} webConsoleUI
  *        The webConsoleUI instance.
@@ -179,27 +181,58 @@ function createContextMenu(webConsoleUI,
     accesskey: l10n.getStr("webconsole.menu.selectAll.accesskey"),
     disabled: false,
     click: () => {
       const webconsoleOutput = parentNode.querySelector(".webconsole-output");
       selection.selectAllChildren(webconsoleOutput);
     },
   }));
 
+  const exportSubmenu = new Menu({
+    id: "export-submenu",
+  });
+
   // Export to clipboard
-  menu.append(new MenuItem({
+  exportSubmenu.append(new MenuItem({
     id: "console-menu-export-clipboard",
-    label: l10n.getStr("webconsole.menu.exportClipboard.label"),
+    label: l10n.getStr("webconsole.menu.exportSubmenu.exportCliboard.label"),
     disabled: false,
     click: () => {
       const webconsoleOutput = parentNode.querySelector(".webconsole-output");
       clipboardHelper.copyString(getElementText(webconsoleOutput));
     },
   }));
 
+  // Export to file
+  exportSubmenu.append(new MenuItem({
+    id: "console-menu-export-file",
+    label: l10n.getStr("webconsole.menu.exportSubmenu.exportFile.label"),
+    disabled: false,
+    click: async () => {
+      const date = new Date();
+      const suggestedName = `console-export-${date.getFullYear()}-` +
+        `${date.getMonth() + 1}-${date.getDate()}_${date.getHours()}-` +
+        `${date.getMinutes()}-${date.getSeconds()}.txt`;
+      const returnFile = await showSaveFileDialog(win, suggestedName);
+      const converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+                        .createInstance(Ci.nsIScriptableUnicodeConverter);
+      converter.charset = "UTF-8";
+      const webconsoleOutput = parentNode.querySelector(".webconsole-output");
+      const istream = converter.convertToInputStream(getElementText(webconsoleOutput));
+      return saveFileStream(returnFile, istream);
+    },
+  }));
+
+  menu.append(new MenuItem({
+    id: "console-menu-export",
+    label: l10n.getStr("webconsole.menu.exportSubmenu.label"),
+    disabled: false,
+    submenu: exportSubmenu,
+  }));
+
   // Open object in sidebar.
   if (openSidebar) {
     menu.append(new MenuItem({
       id: "console-menu-open-sidebar",
       label: l10n.getStr("webconsole.menu.openInSidebar.label"),
       accesskey: l10n.getStr("webconsole.menu.openInSidebar.accesskey"),
       disabled: !rootActorId,
       click: () => openSidebar(message.messageId),
--- a/devtools/shared/DevToolsUtils.js
+++ b/devtools/shared/DevToolsUtils.js
@@ -3,17 +3,17 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* globals setImmediate, rpc */
 
 "use strict";
 
 /* General utilities used throughout devtools. */
 
-var { Ci, Cu, components } = require("chrome");
+var { Ci, Cc, Cu, components } = require("chrome");
 var Services = require("Services");
 var flags = require("./flags");
 var {getStack, callFunctionWithAsyncStack} = require("devtools/shared/platform/stack");
 
 loader.lazyRequireGetter(this, "FileUtils",
                          "resource://gre/modules/FileUtils.jsm", true);
 
 // Using this name lets the eslint plugin know about lazy defines in
@@ -735,16 +735,68 @@ exports.openFileStream = function(filePa
         }
 
         resolve(stream);
       }
     );
   });
 };
 
+/**
+ * Open the file at the given path for writing.
+ *
+ * @param {String} filePath
+ */
+exports.saveFileStream = function(filePath, istream) {
+  return new Promise((resolve, reject) => {
+    const ostream = FileUtils.openSafeFileOutputStream(filePath);
+    NetUtil.asyncCopy(istream, ostream, (status) => {
+      if (!components.isSuccessCode(status)) {
+        reject(new Error(`Could not save "${filePath}"`));
+        return;
+      }
+      FileUtils.closeSafeFileOutputStream(ostream);
+      resolve();
+    });
+  });
+};
+
+/**
+ * Show file picker and return the file user selected.
+ *
+ * @param nsIWindow parentWindow
+ *        Optional parent window. If null the parent window of the file picker
+ *        will be the window of the attached input element.
+ * @param AString suggestedFilename
+ *        The suggested filename when toSave is true.
+ *
+ * @return Promise
+ *         A promise that is resolved after the file is selected by the file picker
+ */
+exports.showSaveFileDialog = function(parentWindow, suggestedFilename) {
+  const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+
+  if (suggestedFilename) {
+    fp.defaultString = suggestedFilename;
+  }
+
+  fp.init(parentWindow, null, fp.modeSave);
+  fp.appendFilters(fp.filterAll);
+
+  return new Promise((resolve, reject) => {
+    fp.open((result) => {
+      if (result == Ci.nsIFilePicker.returnCancel) {
+        reject();
+      } else {
+        resolve(fp.file);
+      }
+    });
+  });
+};
+
 /*
  * All of the flags have been moved to a different module. Make sure
  * nobody is accessing them anymore, and don't write new code using
  * them. We can remove this code after a while.
  */
 function errorOnFlag(exports, name) {
   Object.defineProperty(exports, name, {
     get: () => {
new file mode 100644
--- /dev/null
+++ b/devtools/shared/tests/unit/test_console_save-file.js
@@ -0,0 +1,45 @@
+/* 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";
+
+// Tests for DevToolsUtils.saveFileStream file:
+
+const { FileUtils } = ChromeUtils.import("resource://gre/modules/FileUtils.jsm", {});
+
+/**
+ * Tests that a file is properly saved using the saveFileStream function
+ */
+add_task(async function test_save_file() {
+  const testText = "This is a text";
+  const fileName = "test_console_save-file-" + Math.random();
+  const file = FileUtils.getFile("TmpD", [fileName]);
+  info("Test creating temporary file: " + file.path);
+  await DevToolsUtils.saveFileStream(file, convertToInputStream(testText));
+  Assert.ok(file.exists(), "Checking if test file exists");
+  const { content } = await DevToolsUtils.fetch(file.path);
+  deepEqual(content, testText, "The content was correct.");
+  cleanup(fileName);
+});
+
+/**
+ * Converts a string to an input stream.
+ * @param String content
+ * @return nsIInputStream
+ */
+function convertToInputStream(content) {
+  const converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
+                    .createInstance(Ci.nsIScriptableUnicodeConverter);
+  converter.charset = "UTF-8";
+  return converter.convertToInputStream(content);
+}
+
+/**
+ * Removes the temporary file after the test completes.
+ */
+function cleanup(fileName) {
+  const file = FileUtils.getFile("TmpD", [fileName]);
+  registerCleanupFunction(() => {
+    file.remove(false);
+  });
+}
--- a/devtools/shared/tests/unit/xpcshell.ini
+++ b/devtools/shared/tests/unit/xpcshell.ini
@@ -28,16 +28,17 @@ run-if = nightly_build
 [test_flatten.js]
 [test_indentation.js]
 [test_independent_loaders.js]
 [test_invisible_loader.js]
 [test_isSet.js]
 [test_safeErrorString.js]
 [test_defineLazyPrototypeGetter.js]
 [test_console_filtering.js]
+[test_console_save-file.js]
 [test_pluralForm-english.js]
 [test_pluralForm-makeGetter.js]
 [test_prettifyCSS.js]
 [test_require_lazy.js]
 [test_require_raw.js]
 [test_require.js]
 [test_sprintfjs.js]
 [test_stack.js]