Bug 943306 - Allow persisting console input history between sessions;r=past
authorBrian Grinstead <bgrinstead@mozilla.com>
Thu, 26 Feb 2015 07:45:39 -0800
changeset 231031 8bccd5a79c1a
parent 231030 331a66399399
child 231032 d40e3d27f8e1
push id28342
push userkwierso@gmail.com
push date2015-02-27 02:37 +0000
treeherdermozilla-central@fe31877d9d30 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerspast
bugs943306
milestone39.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 943306 - Allow persisting console input history between sessions;r=past
browser/app/profile/firefox.js
browser/devtools/webconsole/test/browser.ini
browser/devtools/webconsole/test/browser_console_history_persist.js
browser/devtools/webconsole/test/head.js
browser/devtools/webconsole/webconsole.js
toolkit/devtools/webconsole/client.js
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1572,16 +1572,19 @@ pref("devtools.browserconsole.filter.war
 pref("devtools.browserconsole.filter.info", true);
 pref("devtools.browserconsole.filter.log", true);
 pref("devtools.browserconsole.filter.secerror", true);
 pref("devtools.browserconsole.filter.secwarn", true);
 
 // Text size in the Web Console. Use 0 for the system default size.
 pref("devtools.webconsole.fontSize", 0);
 
+// Max number of inputs to store in web console history.
+pref("devtools.webconsole.inputHistoryCount", 50);
+
 // Persistent logging: |true| if you want the Web Console to keep all of the
 // logged messages after reloading the page, |false| if you want the output to
 // be cleared each time page navigation happens.
 pref("devtools.webconsole.persistlog", false);
 
 // Web Console timestamp: |true| if you want the logs and instructions
 // in the Web Console to display a timestamp, or |false| to not display
 // any timestamps.
--- a/browser/devtools/webconsole/test/browser.ini
+++ b/browser/devtools/webconsole/test/browser.ini
@@ -349,16 +349,17 @@ skip-if = e10s # Bug 1042253 - webconsol
 [browser_webconsole_log_file_filter.js]
 [browser_webconsole_expandable_timestamps.js]
 [browser_webconsole_autocomplete_in_debugger_stackframe.js]
 skip-if = e10s # Bug 1042253 - webconsole tests disabled with e10s
 [browser_webconsole_autocomplete_popup_close_on_tab_switch.js]
 skip-if = e10s # Bug 1042253 - webconsole tests disabled with e10s
 [browser_webconsole_autocomplete-properties-with-non-alphanumeric-names.js]
 [browser_console_hide_jsterm_when_devtools_chrome_enabled_false.js]
+[browser_console_history_persist.js]
 [browser_webconsole_output_01.js]
 skip-if = e10s # Bug 1042253 - webconsole e10s tests
 [browser_webconsole_output_02.js]
 [browser_webconsole_output_03.js]
 [browser_webconsole_output_04.js]
 [browser_webconsole_output_05.js]
 [browser_webconsole_output_06.js]
 [browser_webconsole_output_dom_elements_01.js]
new file mode 100644
--- /dev/null
+++ b/browser/devtools/webconsole/test/browser_console_history_persist.js
@@ -0,0 +1,96 @@
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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/. */
+
+// Test that console command input is persisted across toolbox loads.
+// See Bug 943306.
+
+"use strict";
+
+const TEST_URI = "data:text/html;charset=utf-8,Web Console test for persisting history - bug 943306";
+const INPUT_HISTORY_COUNT = 10;
+
+let test = asyncTest(function* () {
+  info ("Setting custom input history pref to " + INPUT_HISTORY_COUNT);
+  Services.prefs.setIntPref("devtools.webconsole.inputHistoryCount", INPUT_HISTORY_COUNT);
+
+  // First tab: run a bunch of commands and then make sure that you can
+  // navigate through their history.
+  yield loadTab(TEST_URI);
+  let hud1 = yield openConsole();
+  is (JSON.stringify(hud1.jsterm.history), "[]", "No history on first tab initially");
+  yield populateInputHistory(hud1);
+  is (JSON.stringify(hud1.jsterm.history), '["0","1","2","3","4","5","6","7","8","9"]',
+    "First tab has populated history");
+
+  // Second tab: Just make sure that you can navigate through the history
+  // generated by the first tab.
+  yield loadTab(TEST_URI);
+  let hud2 = yield openConsole();
+  is (JSON.stringify(hud2.jsterm.history), '["0","1","2","3","4","5","6","7","8","9"]',
+    "Second tab has populated history");
+  yield testNaviatingHistoryInUI(hud2);
+  is (JSON.stringify(hud2.jsterm.history), '["0","1","2","3","4","5","6","7","8","9",""]',
+    "An empty entry has been added in the second tab due to history perusal");
+
+  // Third tab: Should have the same history as first tab, but if we run a
+  // command, then the history of the first and second shouldn't be affected
+  yield loadTab(TEST_URI);
+  let hud3 = yield openConsole();
+  is (JSON.stringify(hud3.jsterm.history), '["0","1","2","3","4","5","6","7","8","9"]',
+    "Third tab has populated history");
+
+  // Set input value separately from execute so UP arrow accurately navigates history.
+  hud3.jsterm.setInputValue('"hello from third tab"');
+  hud3.jsterm.execute();
+
+  is (JSON.stringify(hud1.jsterm.history), '["0","1","2","3","4","5","6","7","8","9"]',
+    "First tab history hasn't changed due to command in third tab");
+  is (JSON.stringify(hud2.jsterm.history), '["0","1","2","3","4","5","6","7","8","9",""]',
+    "Second tab history hasn't changed due to command in third tab");
+  is (JSON.stringify(hud3.jsterm.history), '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
+    "Third tab has updated history (and purged the first result) after running a command");
+
+  // Fourth tab: Should have the latest command from the third tab, followed
+  // by the rest of the history from the first tab.
+  yield loadTab(TEST_URI);
+  let hud4 = yield openConsole();
+  is (JSON.stringify(hud4.jsterm.history), '["1","2","3","4","5","6","7","8","9","\\"hello from third tab\\""]',
+    "Fourth tab has most recent history");
+
+  info ("Clearing custom input history pref");
+  Services.prefs.clearUserPref("devtools.webconsole.inputHistoryCount");
+});
+
+/**
+ * Populate the history by running the following commands:
+ *  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+ */
+function* populateInputHistory(hud) {
+  let jsterm = hud.jsterm;
+  let {inputNode} = jsterm;
+
+  for (let i = 0; i < INPUT_HISTORY_COUNT; i++) {
+    // Set input value separately from execute so UP arrow accurately navigates history.
+    jsterm.setInputValue(i);
+    jsterm.execute();
+  }
+}
+
+/**
+ * Check pressing up results in history traversal like:
+ *  [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
+ */
+function* testNaviatingHistoryInUI(hud) {
+  let jsterm = hud.jsterm;
+  let {inputNode} = jsterm;
+  inputNode.focus();
+
+  // Count backwards from original input and make sure that pressing up
+  // restores this.
+  for (let i = INPUT_HISTORY_COUNT - 1; i >= 0; i--) {
+    EventUtils.synthesizeKey("VK_UP", {});
+    is(inputNode.value, i, "Pressing up restores last input");
+  }
+}
--- a/browser/devtools/webconsole/test/head.js
+++ b/browser/devtools/webconsole/test/head.js
@@ -8,16 +8,17 @@
 let {gDevTools} = Cu.import("resource:///modules/devtools/gDevTools.jsm", {});
 let {console} = Cu.import("resource://gre/modules/devtools/Console.jsm", {});
 let {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {});
 let {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
 let {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
 let {require, TargetFactory} = devtools;
 let {Utils: WebConsoleUtils} = require("devtools/toolkit/webconsole/utils");
 let {Messages} = require("devtools/webconsole/console-output");
+const asyncStorage = require("devtools/toolkit/shared/async-storage");
 
 // promise._reportErrors = true; // please never leave me.
 //Services.prefs.setBoolPref("devtools.debugger.log", true);
 
 let gPendingOutputTest = 0;
 
 // The various categories of messages.
 const CATEGORY_NETWORK = 0;
@@ -317,16 +318,19 @@ let finishTest = Task.async(function* ()
   yield gDevTools.closeToolbox(target);
 
   finish();
 });
 
 registerCleanupFunction(function*() {
   gDevTools.testing = false;
 
+  // Remove stored console commands in between tests
+  yield asyncStorage.removeItem("webConsoleHistory");
+
   dumpConsoles();
 
   if (HUDService.getBrowserConsole()) {
     HUDService.toggleBrowserConsole();
   }
 
   let target = TargetFactory.forTab(gBrowser.selectedTab);
   yield gDevTools.closeToolbox(target);
--- a/browser/devtools/webconsole/webconsole.js
+++ b/browser/devtools/webconsole/webconsole.js
@@ -21,16 +21,18 @@ loader.lazyGetter(this, "AutocompletePop
 loader.lazyGetter(this, "ToolSidebar",
                   () => require("devtools/framework/sidebar").ToolSidebar);
 loader.lazyGetter(this, "NetworkPanel",
                   () => require("devtools/webconsole/network-panel").NetworkPanel);
 loader.lazyGetter(this, "ConsoleOutput",
                   () => require("devtools/webconsole/console-output").ConsoleOutput);
 loader.lazyGetter(this, "Messages",
                   () => require("devtools/webconsole/console-output").Messages);
+loader.lazyGetter(this, "asyncStorage",
+                  () => require("devtools/toolkit/shared/async-storage"));
 loader.lazyImporter(this, "EnvironmentClient", "resource://gre/modules/devtools/dbg-client.jsm");
 loader.lazyImporter(this, "ObjectClient", "resource://gre/modules/devtools/dbg-client.jsm");
 loader.lazyImporter(this, "VariablesView", "resource:///modules/devtools/VariablesView.jsm");
 loader.lazyImporter(this, "VariablesViewController", "resource:///modules/devtools/VariablesViewController.jsm");
 loader.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm");
 loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
 
 const STRINGS_URI = "chrome://browser/locale/devtools/webconsole.properties";
@@ -171,16 +173,17 @@ const THROTTLE_UPDATES = 1000; // millis
 const FILTER_PREFS_PREFIX = "devtools.webconsole.filter.";
 
 // The minimum font size.
 const MIN_FONT_SIZE = 10;
 
 const PREF_CONNECTION_TIMEOUT = "devtools.debugger.remote-timeout";
 const PREF_PERSISTLOG = "devtools.webconsole.persistlog";
 const PREF_MESSAGE_TIMESTAMP = "devtools.webconsole.timestampMessages";
+const PREF_INPUT_HISTORY_COUNT = "devtools.webconsole.inputHistoryCount";
 
 /**
  * A WebConsoleFrame instance is an interactive console initialized *per target*
  * that displays console log data as well as provides an interactive terminal to
  * manipulate the target's document content.
  *
  * The WebConsoleFrame is responsible for the actual Web Console UI
  * implementation.
@@ -438,22 +441,39 @@ WebConsoleFrame.prototype = {
    */
   get persistLog() {
     return Services.prefs.getBoolPref(PREF_PERSISTLOG);
   },
 
   /**
    * Initialize the WebConsoleFrame instance.
    * @return object
-   *         A promise object for the initialization.
+   *         A promise object that resolves once the frame is ready to use.
    */
-  init: function WCF_init()
+  init: function()
   {
     this._initUI();
-    return this._initConnection();
+    let connectionInited = this._initConnection();
+
+    // Don't reject if the history fails to load for some reason.
+    // This would be fine, the panel will just start with empty history.
+    let allReady = this.jsterm.historyLoaded.catch(() => {}).then(() => {
+      return connectionInited;
+    });
+
+    // This notification is only used in tests. Don't chain it onto
+    // the returned promise because the console panel needs to be attached
+    // to the toolbox before the web-console-created event is receieved.
+    let notifyObservers = () => {
+      let id = WebConsoleUtils.supportsString(this.hudId);
+      Services.obs.notifyObservers(id, "web-console-created", null);
+    };
+    allReady.then(notifyObservers, notifyObservers);
+
+    return allReady;
   },
 
   /**
    * Connect to the server using the remote debugging protocol.
    *
    * @private
    * @return object
    *         A promise object that is resolved/reject based on the connection
@@ -470,19 +490,16 @@ WebConsoleFrame.prototype = {
 
     this.proxy.connect().then(() => { // on success
       this._initDefer.resolve(this);
     }, (aReason) => { // on failure
       let node = this.createMessageNode(CATEGORY_JS, SEVERITY_ERROR,
                                         aReason.error + ": " + aReason.message);
       this.outputMessage(CATEGORY_JS, node, [aReason]);
       this._initDefer.reject(aReason);
-    }).then(() => {
-      let id = WebConsoleUtils.supportsString(this.hudId);
-      Services.obs.notifyObservers(id, "web-console-created", null);
     });
 
     return this._initDefer.promise;
   },
 
   /**
    * Find the Web Console UI elements and setup event listeners as needed.
    * @private
@@ -3049,42 +3066,68 @@ function getterOrSetterEvalMacro(aItem, 
  * @constructor
  * @param object aWebConsoleFrame
  *        The WebConsoleFrame object that owns this JSTerm instance.
  */
 function JSTerm(aWebConsoleFrame)
 {
   this.hud = aWebConsoleFrame;
   this.hudId = this.hud.hudId;
+  this.inputHistoryCount = Services.prefs.getIntPref(PREF_INPUT_HISTORY_COUNT);
 
   this.lastCompletion = { value: null };
-  this.history = [];
-
-  // Holds the number of entries in history. This value is incremented in
-  // this.execute().
-  this.historyIndex = 0; // incremented on this.execute()
-
-  // Holds the index of the history entry that the user is currently viewing.
-  // This is reset to this.history.length when this.execute() is invoked.
-  this.historyPlaceHolder = 0;
+  this._loadHistory();
+
   this._objectActorsInVariablesViews = new Map();
 
   this._keyPress = this._keyPress.bind(this);
   this._inputEventHandler = this._inputEventHandler.bind(this);
   this._focusEventHandler = this._focusEventHandler.bind(this);
   this._onKeypressInVariablesView = this._onKeypressInVariablesView.bind(this);
   this._blurEventHandler = this._blurEventHandler.bind(this);
 
   EventEmitter.decorate(this);
 }
 
 JSTerm.prototype = {
   SELECTED_FRAME: -1,
 
   /**
+   * Load the console history from previous sessions.
+   * @private
+   */
+  _loadHistory: function() {
+    this.history = [];
+    this.historyIndex = this.historyPlaceHolder = 0;
+
+    this.historyLoaded = asyncStorage.getItem("webConsoleHistory").then(value => {
+      if (Array.isArray(value)) {
+        // Since it was gotten asynchronously, there could be items already in
+        // the history.  It's not likely but stick them onto the end anyway.
+        this.history = value.concat(this.history);
+
+        // Holds the number of entries in history. This value is incremented in
+        // this.execute().
+        this.historyIndex = this.history.length;
+
+        // Holds the index of the history entry that the user is currently viewing.
+        // This is reset to this.history.length when this.execute() is invoked.
+        this.historyPlaceHolder = this.history.length;
+      }
+    }, console.error);
+  },
+
+  /**
+   * Stores the console history for future sessions.
+   */
+  storeHistory: function() {
+    asyncStorage.setItem("webConsoleHistory", this.history);
+  },
+
+  /**
    * Stores the data for the last completion.
    * @type object
    */
   lastCompletion: null,
 
   /**
    * Array that caches the user input suggestions received from the server.
    * @private
@@ -3383,16 +3426,22 @@ JSTerm.prototype = {
 
     this.requestEvaluation(aExecuteString, options).then(onResult, onResult);
 
     // Append a new value in the history of executed code, or overwrite the most
     // recent entry. The most recent entry may contain the last edited input
     // value that was not evaluated yet.
     this.history[this.historyIndex++] = aExecuteString;
     this.historyPlaceHolder = this.history.length;
+
+    if (this.history.length > this.inputHistoryCount) {
+      this.history.splice(0, this.history.length - this.inputHistoryCount);
+      this.historyIndex = this.historyPlaceHolder = this.history.length;
+    }
+    this.storeHistory();
     WebConsoleUtils.usageCount++;
     this.setInputValue("");
     this.clearCompletion();
     return deferred.promise;
   },
 
   /**
    * Request a JavaScript string evaluation from the server.
--- a/toolkit/devtools/webconsole/client.js
+++ b/toolkit/devtools/webconsole/client.js
@@ -149,17 +149,21 @@ WebConsoleClient.prototype = {
       text: aString,
       bindObjectActor: aOptions.bindObjectActor,
       frameActor: aOptions.frameActor,
       url: aOptions.url,
       selectedNodeActor: aOptions.selectedNodeActor,
     };
 
     this._client.request(packet, response => {
-      this.pendingEvaluationResults.set(response.resultID, aOnResponse);
+      // Null check this in case the client has been detached while waiting
+      // for a response.
+      if (this.pendingEvaluationResults) {
+        this.pendingEvaluationResults.set(response.resultID, aOnResponse);
+      }
     });
   },
 
   /**
    * Handler for the actors's unsolicited evaluationResult packet.
    */
   onEvaluationResult: function(aNotification, aPacket) {
     // Find the associated callback based on this ID, and fire it.