Bug 573102 - JS/DOM Object Introspector, r=gavin, a=blocking
authorJulian Viereck <jviereck@mozilla.com>
Wed, 11 Aug 2010 20:38:02 -0300
changeset 49585 8b3fd544de43c059753d368c773d725c09637808
parent 49584 e31c822d20854c58aae22c052e135996c8aead7a
child 49586 e77d84f9ebe42aa07fd616d79f1b27149e58ccbf
push idunknown
push userunknown
push dateunknown
reviewersgavin, blocking
bugs573102
milestone2.0b4pre
Bug 573102 - JS/DOM Object Introspector, r=gavin, a=blocking
toolkit/components/console/hudservice/HUDService.jsm
toolkit/components/console/hudservice/Makefile.in
toolkit/components/console/hudservice/PropertyPanel.jsm
toolkit/components/console/hudservice/tests/browser/browser_HUDServiceTestsAll.js
toolkit/locales/en-US/chrome/global/headsUpDisplay.properties
--- a/toolkit/components/console/hudservice/HUDService.jsm
+++ b/toolkit/components/console/hudservice/HUDService.jsm
@@ -58,16 +58,27 @@ XPCOMUtils.defineLazyServiceGetter(this,
 XPCOMUtils.defineLazyServiceGetter(this, "activityDistributor",
                                    "@mozilla.org/network/http-activity-distributor;1",
                                    "nsIHttpActivityDistributor");
 
 XPCOMUtils.defineLazyServiceGetter(this, "sss",
                                    "@mozilla.org/content/style-sheet-service;1",
                                    "nsIStyleSheetService");
 
+XPCOMUtils.defineLazyGetter(this, "PropertyPanel", function () {
+  var obj = {};
+  try {
+    Cu.import("resource://gre/modules/PropertyPanel.jsm", obj);
+  } catch (err) {
+    Cu.reportError(err);
+  }
+  return obj.PropertyPanel;
+});
+
+
 function LogFactory(aMessagePrefix)
 {
   function log(aMessage) {
     var _msg = aMessagePrefix + " " + aMessage + "\n";
     dump(_msg);
   }
   return log;
 }
@@ -2522,56 +2533,174 @@ JSTerm.prototype = {
     this.sandbox.console = this.console;
     this.sandbox.__proto__ = this._window.wrappedJSObject;
   },
 
   get _window()
   {
     return this.context.get().QueryInterface(Ci.nsIDOMWindowInternal);
   },
+  /**
+   * Evaluates a string in the sandbox. The string is currently wrapped by a
+   * with(window) { aString } construct, see bug 574033.
+   *
+   * @param string aString
+   *        String to evaluate in the sandbox.
+   * @returns something
+   *          The result of the evaluation.
+   */
+  evalInSandbox: function JST_evalInSandbox(aString)
+  {
+    let execStr = "with(window) {" + aString + "}";
+    return Cu.evalInSandbox(execStr,  this.sandbox, "default", "HUD Console", 1);
+  },
+
 
   execute: function JST_execute(aExecuteString)
   {
     // attempt to execute the content of the inputNode
-    var str = aExecuteString || this.inputNode.value;
-    if (!str) {
+    aExecuteString = aExecuteString || this.inputNode.value;
+    if (!aExecuteString) {
       this.console.log("no value to execute");
       return;
     }
 
-    this.writeOutput(str, true);
+    this.writeOutput(aExecuteString, true);
 
     try {
-      var execStr = "with(window) {" + str + "}";
-      var result =
-        Cu.evalInSandbox(execStr,  this.sandbox, "default", "HUD Console", 1);
-
-      if (result || result === false || result === " ") {
-        this.writeOutput(result, false);
+      var result = this.evalInSandbox(aExecuteString);
+
+      if (result || result === false) {
+        this.writeOutputJS(aExecuteString, result);
       }
       else if (result === undefined) {
         this.writeOutput("undefined", false);
       }
       else if (result === null) {
         this.writeOutput("null", false);
       }
     }
     catch (ex) {
-      if (ex) {
-        this.console.error(ex);
-      }
+      this.console.error(ex);
     }
 
-    this.history.push(str);
+    this.history.push(aExecuteString);
     this.historyIndex++;
     this.historyPlaceHolder = this.history.length;
     this.inputNode.value = "";
   },
 
   /**
+   * Opens a new PropertyPanel. The panel has two buttons: "Update" reexecutes
+   * the passed aEvalString and places the result inside of the tree. The other
+   * button closes the panel.
+   *
+   * @param string aEvalString
+   *        String that was used to eval the aOutputObject. Used as title
+   *        and to update the tree content.
+   * @param object aOutputObject
+   *        Object to display/inspect inside of the tree.
+   * @param nsIDOMNode aAnchor
+   *        A node to popup the panel next to (using "after_pointer").
+   * @returns object the created and opened propertyPanel.
+   */
+  openPropertyPanel: function JST_openPropertyPanel(aEvalString, aOutputObject,
+                                                    aAnchor)
+  {
+    let self = this;
+    let propPanel;
+    // The property panel has two buttons:
+    // 1. `Update`: reexecutes the string executed on the command line. The
+    //    result will be inspected by this panel.
+    // 2. `Close`: destroys the panel.
+    let buttons = [];
+
+    // If there is a evalString passed to this function, then add a `Update`
+    // button to the panel so that the evalString can be reexecuted to update
+    // the content of the panel.
+    if (aEvalString !== null) {
+      buttons.push({
+        label: HUDService.getStr("update.button"),
+        accesskey: HUDService.getStr("update.accesskey"),
+        oncommand: function () {
+          try {
+            var result = self.evalInSandbox(aEvalString);
+
+            if (result !== undefined) {
+              // TODO: This updates the value of the tree.
+              // However, the states of opened nodes is not saved.
+              // See bug 586246.
+              propPanel.treeView.data = result;
+            }
+          }
+          catch (ex) {
+            self.console.error(ex);
+          }
+        }
+      });
+    }
+
+    buttons.push({
+      label: HUDService.getStr("close.button"),
+      accesskey: HUDService.getStr("close.accesskey"),
+      oncommand: function () {
+        propPanel.destroy();
+      }
+    });
+
+    let doc = self.parentNode.ownerDocument;
+    let parent = doc.getElementById("mainPopupSet");
+    let title = (aEvalString
+        ? HUDService.getFormatStr("jsPropertyInspectTitle", [aEvalString])
+        : HUDService.getStr("jsPropertyTitle"));
+    propPanel = new PropertyPanel(parent, doc, title, aOutputObject, buttons);
+
+    let panel = propPanel.panel;
+    panel.openPopup(aAnchor, "after_pointer", 0, 0, false, false);
+    panel.sizeTo(200, 400);
+    return propPanel;
+  },
+
+  /**
+   * Writes a JS object to the JSTerm outputNode. If the user clicks on the
+   * written object, openPropertyPanel is called to open up a panel to inspect
+   * the object.
+   *
+   * @param string aEvalString
+   *        String that was evaluated to get the aOutputObject.
+   * @param object aOutputObject
+   *        Object to be written to the outputNode.
+   */
+  writeOutputJS: function JST_writeOutputJS(aEvalString, aOutputObject)
+  {
+    let lastGroupNode = HUDService.appendGroupIfNecessary(this.outputNode,
+                                                      Date.now());
+    var node = this.elementFactory("div");
+    node.setAttribute("class", "jsterm-output-line");
+
+    var self = this;
+    var link = this.elementFactory("a");
+    link.setAttribute("href", "javascript:");
+    link.setAttribute("aria-haspopup", "true");
+    link.onclick = function() {
+      self.openPropertyPanel(aEvalString, aOutputObject, link);
+    }
+
+    // TODO: format the aOutputObject and don't just use the
+    // aOuputObject.toString() function: [object object] -> Object {prop, ...}
+    // See bug 586249.
+    textNode = this.textFactory(aOutputObject);
+    link.appendChild(textNode);
+    node.appendChild(link);
+
+    lastGroupNode.appendChild(node);
+    node.scrollIntoView(false);
+  },
+
+  /**
    * Writes a message to the HUD that originates from the interactive
    * JavaScript console.
    *
    * @param string aOutputMessage
    *        The message to display.
    * @param boolean aIsInput
    *        True if the message is the user's input, false if the message is
    *        the result of the expression the user typed.
--- a/toolkit/components/console/hudservice/Makefile.in
+++ b/toolkit/components/console/hudservice/Makefile.in
@@ -41,16 +41,17 @@ topsrcdir	= @top_srcdir@
 srcdir		= @srcdir@
 VPATH		= @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 MODULE = hudservice
 
 EXTRA_JS_MODULES = HUDService.jsm \
+		PropertyPanel.jsm \
 		$(NULL)
 
 ifdef ENABLE_TESTS
 ifneq (mobile,$(MOZ_BUILD_APP))
 	DIRS += tests
 endif
 endif
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/console/hudservice/PropertyPanel.jsm
@@ -0,0 +1,496 @@
+/* -*- Mode: js2; js2-basic-offset: 2; indent-tabs-mode: nil; -*- */
+/* ***** BEGIN LICENSE BLOCK *****
+ * Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ * http://www.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is DevTools (HeadsUpDisplay) Console Code
+ *
+ * The Initial Developer of the Original Code is
+ * the Mozilla Foundation.
+ * Portions created by the Initial Developer are Copyright (C) 2010
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *   Rob Campbell <rcampbell@mozilla.com>
+ *   Julian Viereck <jviereck@mozilla.com>
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+var EXPORTED_SYMBOLS = ["PropertyPanel", "PropertyTreeView"];
+
+///////////////////////////////////////////////////////////////////////////
+//// Helper for PropertyTreeView
+
+const TYPE_OBJECT = 0, TYPE_FUNCTION = 1, TYPE_ARRAY = 2, TYPE_OTHER = 3;
+
+/**
+ * Figures out the type of aObject and the string to display in the tree.
+ *
+ * @param object aObject
+ *        The object to operate on.
+ * @returns object
+ *          A object with the form:
+ *            {
+ *              type: TYPE_OBJECT || TYPE_FUNCTION || TYPE_ARRAY || TYPE_OTHER,
+ *              display: string for displaying the object in the tree
+ *            }
+ */
+function presentableValueFor(aObject)
+{
+  if (aObject === null || aObject === undefined) {
+    return {
+      type: TYPE_OTHER,
+      display: aObject === undefined ? "undefined" : "null"
+    };
+  }
+
+  let presentable;
+  switch (aObject.constructor && aObject.constructor.name) {
+    case "Array":
+      return {
+        type: TYPE_ARRAY,
+        display: "Array"
+      };
+
+    case "String":
+      return {
+        type: TYPE_OTHER,
+        display: "\"" + aObject + "\""
+      };
+
+    case "Date":
+    case "RegExp":
+    case "Number":
+    case "Boolean":
+      return {
+        type: TYPE_OTHER,
+        display: aObject
+      };
+
+    case "Function":
+      presentable = aObject.toString();
+      return {
+        type: TYPE_FUNCTION,
+        display: presentable.substring(0, presentable.indexOf(')') + 1)
+      };
+
+    default:
+      presentable = aObject.toString();
+      let m = /^\[object (\S+)\]/.exec(presentable);
+      let display;
+
+      return {
+        type: TYPE_OBJECT,
+        display: m ? m[1] : "Object"
+      };
+  }
+}
+
+/**
+ * Get an array of property name value pairs for the tree.
+ *
+ * @param object aObject
+ *        The object to get properties for.
+ * @returns array of object
+ *          Objects have the name, value, display, type, children properties.
+ */
+function namesAndValuesOf(aObject)
+{
+  let pairs = [];
+  let value, presentable;
+
+  for (var propName in aObject) {
+    try {
+      value = aObject[propName];
+      presentable = presentableValueFor(value);
+    }
+    catch (ex) {
+      continue;
+    }
+
+    let pair = {};
+    pair.name = propName;
+    pair.display = propName + ": " + presentable.display;
+    pair.type = presentable.type;
+    pair.value = value;
+
+    // Convert the pair.name to a number for later sorting.
+    pair.nameNumber = parseFloat(pair.name)
+    if (isNaN(pair.nameNumber)) {
+      pair.nameNumber = false;
+    }
+
+    pairs.push(pair);
+  }
+
+  pairs.sort(function(a, b)
+  {
+    // Sort numbers.
+    if (a.nameNumber !== false && b.nameNumber === false) {
+      return -1;
+    }
+    else if (a.nameNumber === false && b.nameNumber !== false) {
+      return 1;
+    }
+    else if (a.nameNumber !== false && b.nameNumber !== false) {
+      return a.nameNumber - b.nameNumber;
+    }
+    // Sort string.
+    else if (a.name < b.name) {
+      return -1;
+    }
+    else if (a.name > b.name) {
+      return 1;
+    }
+    else {
+      return 0;
+    }
+  });
+
+  return pairs;
+}
+
+///////////////////////////////////////////////////////////////////////////
+//// PropertyTreeView.
+
+
+/**
+ * This is an implementation of the nsITreeView interface. For comments on the
+ * interface properties, see the documentation:
+ * https://developer.mozilla.org/en/XPCOM_Interface_Reference/nsITreeView
+ */
+var PropertyTreeView = function() {
+  this._rows = [];
+};
+
+PropertyTreeView.prototype = {
+
+  /**
+   * Stores the visible rows of the tree.
+   */
+  _rows: null,
+
+  /**
+   * Stores the nsITreeBoxObject for this tree.
+   */
+  _treeBox: null,
+
+  /**
+   * Use this setter to update the content of the tree.
+   *
+   * @param object aObject
+   *        The new object to be displayed in the tree.
+   * @returns void
+   */
+  set data(aObject) {
+    let oldLen = this._rows.length;
+    this._rows = this.getChildItems(aObject, true);
+    if (this._treeBox) {
+      this._treeBox.beginUpdateBatch();
+      if (oldLen) {
+        this._treeBox.rowCountChanged(0, -oldLen);
+      }
+      this._treeBox.rowCountChanged(0, this._rows.length);
+      this._treeBox.endUpdateBatch();
+    }
+  },
+
+  /**
+   * Generates the child items for the treeView of a given aItem. If there is
+   * already a children property on the aItem, this cached one is returned.
+   *
+   * @param object aItem
+   *        An item of the tree's elements to generate the children for.
+   * @param boolean aRootElement
+   *        If set, aItem is handled as an JS object and not as an item
+   *        element of the tree.
+   * @returns array of objects
+   *        Child items of aItem.
+   */
+  getChildItems: function(aItem, aRootElement)
+  {
+    // If item.children is an array, then the children has already been
+    // computed and can get returned directly.
+    // Skip this checking if aRootElement is true. It could happen, that aItem
+    // is passed as ({children:[1,2,3]}) which would be true, although these
+    // "kind" of children has no value/type etc. data as needed to display in
+    // the tree. As the passed ({children:[1,2,3]}) are instanceof
+    // itsWindow.Array and not this modules's global Array
+    // aItem.children instanceof Array can't be true, but for saftey the
+    // !aRootElement is kept here.
+    if (!aRootElement && aItem && aItem.children instanceof Array) {
+      return aItem.children;
+    }
+
+    let pairs;
+    let newPairLevel;
+
+    if (!aRootElement) {
+      newPairLevel = aItem.level + 1;
+      aItem = aItem.value;
+    }
+    else {
+      newPairLevel = 0;
+    }
+
+    pairs = namesAndValuesOf(aItem);
+
+    for each (var pair in pairs) {
+      pair.level = newPairLevel;
+      pair.isOpened = false;
+      pair.children = pair.type == TYPE_OBJECT || pair.type == TYPE_FUNCTION ||
+                      pair.type == TYPE_ARRAY;
+    }
+
+    return pairs;
+  },
+
+  /** nsITreeView interface implementation **/
+
+  selection: null,
+
+  get rowCount()                     { return this._rows.length; },
+  setTree: function(treeBox)         { this._treeBox = treeBox;  },
+  getCellText: function(idx, column) { return this._rows[idx].display; },
+  getLevel: function(idx)            { return this._rows[idx].level; },
+  isContainer: function(idx)         { return !!this._rows[idx].children; },
+  isContainerOpen: function(idx)     { return this._rows[idx].isOpened; },
+  isContainerEmpty: function(idx)    { return false; },
+  isSeparator: function(idx)         { return false; },
+  isSorted: function()               { return false; },
+  isEditable: function(idx, column)  { return false; },
+  isSelectable: function(row, col)   { return true; },
+
+  getParentIndex: function(idx)
+  {
+    if (this.getLevel(idx) == 0) {
+      return -1;
+    }
+    for (var t = idx - 1; t >= 0 ; t--) {
+      if (this.isContainer(t)) {
+        return t;
+      }
+    }
+    return -1;
+  },
+
+  hasNextSibling: function(idx, after)
+  {
+    var thisLevel = this.getLevel(idx);
+    return this._rows.slice(after + 1).some(function (r) r.level == thisLevel);
+  },
+
+  toggleOpenState: function(idx)
+  {
+    var item = this._rows[idx];
+    if (!item.children) {
+      return;
+    }
+
+    this._treeBox.beginUpdateBatch();
+    if (item.isOpened) {
+      item.isOpened = false;
+
+      var thisLevel = item.level;
+      var t = idx + 1, deleteCount = 0;
+      while (t < this._rows.length && this.getLevel(t++) > thisLevel) {
+        deleteCount++;
+      }
+
+      if (deleteCount) {
+        this._rows.splice(idx + 1, deleteCount);
+        this._treeBox.rowCountChanged(idx + 1, -deleteCount);
+      }
+    }
+    else {
+      item.isOpened = true;
+
+      var toInsert = this.getChildItems(item);
+      item.children = toInsert;
+      this._rows.splice.apply(this._rows, [idx + 1, 0].concat(toInsert));
+
+      this._treeBox.rowCountChanged(idx + 1, toInsert.length);
+    }
+    this._treeBox.invalidateRow(idx);
+    this._treeBox.endUpdateBatch();
+  },
+
+  getImageSrc: function(idx, column) { },
+  getProgressMode : function(idx,column) { },
+  getCellValue: function(idx, column) { },
+  cycleHeader: function(col, elem) { },
+  selectionChanged: function() { },
+  cycleCell: function(idx, column) { },
+  performAction: function(action) { },
+  performActionOnCell: function(action, index, column) { },
+  performActionOnRow: function(action, row) { },
+  getRowProperties: function(idx, column, prop) { },
+  getCellProperties: function(idx, column, prop) { },
+  getColumnProperties: function(column, element, prop) { },
+
+  setCellValue: function(row, col, value)               { },
+  setCellText: function(row, col, value)                { },
+  drop: function(index, orientation, dataTransfer)      { },
+  canDrop: function(index, orientation, dataTransfer)   { return false; }
+};
+
+///////////////////////////////////////////////////////////////////////////
+//// Helper for creating the panel.
+
+/**
+ * Creates a DOMNode and sets all the attributes of aAttributes on the created
+ * element.
+ *
+ * @param nsIDOMDocument aDocument
+ *        Document to create the new DOMNode.
+ * @param string aTag
+ *        Name of the tag for the DOMNode.
+ * @param object aAttributes
+ *        Attributes set on the created DOMNode.
+ * @returns nsIDOMNode
+ */
+function createElement(aDocument, aTag, aAttributes)
+{
+  let node = aDocument.createElement(aTag);
+  for (var attr in aAttributes) {
+    node.setAttribute(attr, aAttributes[attr]);
+  }
+  return node;
+}
+
+/**
+ * Creates a new DOMNode and appends it to aParent.
+ *
+ * @param nsIDOMDocument aDocument
+ *        Document to create the new DOMNode.
+ * @param nsIDOMNode aParent
+ *        A parent node to append the created element.
+ * @param string aTag
+ *        Name of the tag for the DOMNode.
+ * @param object aAttributes
+ *        Attributes set on the created DOMNode.
+ * @returns nsIDOMNode
+ */
+function appendChild(aDocument, aParent, aTag, aAttributes)
+{
+  let node = createElement(aDocument, aTag, aAttributes);
+  aParent.appendChild(node);
+  return node;
+}
+
+///////////////////////////////////////////////////////////////////////////
+//// PropertyPanel
+
+/**
+ * Creates a new PropertyPanel.
+ *
+ * @param nsIDOMNode aParent
+ *        Parent node to append the created panel to.
+ * @param nsIDOMDocument aDocument
+ *        Document to create the new nodes on.
+ * @param string aTitle
+ *        Title for the panel.
+ * @param string aObject
+ *        Object to display in the tree.
+ * @param array of objects aButtons
+ *        Array with buttons to display at the bottom of the panel.
+ */
+function PropertyPanel(aParent, aDocument, aTitle, aObject, aButtons)
+{
+  // Create the underlying panel
+  this.panel = createElement(aDocument, "panel", {
+    label: aTitle,
+    titlebar: "normal",
+    noautofocus: "true",
+    noautohide: "true"
+  });
+
+  // Create the tree.
+  let tree = this.tree = createElement(aDocument, "tree", { flex: 1 });
+
+  let treecols = aDocument.createElement("treecols");
+  appendChild(aDocument, treecols, "treecol", {
+    primary: "true",
+    flex: 1
+  });
+  tree.appendChild(treecols);
+
+  tree.appendChild(aDocument.createElement("treechildren"));
+  this.panel.appendChild(tree);
+
+  // Create the footer.
+  let footer = createElement(aDocument, "hbox", { align: "end" });
+  appendChild(aDocument, footer, "spacer", { flex: 1 });
+
+  // The footer can have butttons.
+  if (aButtons) {
+    aButtons.forEach(function(button) {
+      let buttonNode = appendChild(aDocument, footer, "button", {
+        label: button.label,
+        accesskey: button.accesskey || ""
+      });
+      buttonNode.addEventListener("command", function() {
+        button.oncommand();
+      }, false);
+    });
+  }
+
+  appendChild(aDocument, footer, "resizer", { dir: "bottomend" });
+  this.panel.appendChild(footer);
+
+  aParent.appendChild(this.panel);
+
+  // Create the treeView object.
+  this.treeView = new PropertyTreeView();
+  this.treeView.data = aObject;
+
+  // Set the treeView object on the tree view. This has to be done *after* the
+  // panel is shown. This is because the tree binding must be attached first.
+  let self = this;
+  this.panel.addEventListener("popupshown", function onPopupShow()
+  {
+    self.panel.removeEventListener("popupshown", onPopupShow, false);
+    self.tree.view = self.treeView;
+  }, false);
+}
+
+/**
+ * Destroy the PropertyPanel. This closes the poped up panel and removes
+ * it from the browser DOM.
+ *
+ * @returns void
+ */
+PropertyPanel.prototype.destroy = function PP_destory()
+{
+  this.panel.hidePopup();
+  this.panel.parentNode.removeChild(this.panel);
+  this.treeView = null;
+}
--- a/toolkit/components/console/hudservice/tests/browser/browser_HUDServiceTestsAll.js
+++ b/toolkit/components/console/hudservice/tests/browser/browser_HUDServiceTestsAll.js
@@ -380,17 +380,17 @@ function testJSInputAndOutputStyling() {
   let outputChildren = group.childNodes;
   let jsInputNode = outputChildren[1];
   isnot(jsInputNode.childNodes[0].nodeValue.indexOf("2 + 2"), -1,
     "JS input node contains '2 + 2'");
   isnot(jsInputNode.getAttribute("class").indexOf("jsterm-input-line"), -1,
     "JS input node is of the CSS class 'jsterm-input-line'");
 
   let jsOutputNode = outputChildren[2];
-  isnot(jsOutputNode.childNodes[0].nodeValue.indexOf("4"), -1,
+  isnot(jsOutputNode.childNodes[0].textContent.indexOf("4"), -1,
     "JS output node contains '4'");
   isnot(jsOutputNode.getAttribute("class").indexOf("jsterm-output-line"), -1,
     "JS output node is of the CSS class 'jsterm-output-line'");
 }
 
 function testCreateDisplay() {
   ok(typeof cs.consoleDisplays == "object",
      "consoledisplays exist");
@@ -619,20 +619,62 @@ function testExecutionScope()
   let group = jsterm.outputNode.querySelector(".hud-group");
 
   is(group.childNodes.length, 3, "Three children in output");
   let outputChildren = group.childNodes;
 
   is(/location;/.test(outputChildren[1].childNodes[0].nodeValue), true,
     "'location;' written to output");
 
-  isnot(outputChildren[2].childNodes[0].nodeValue.indexOf(TEST_URI), -1,
+  isnot(outputChildren[2].childNodes[0].textContent.indexOf(TEST_URI), -1,
     "command was executed in the window scope");
 }
 
+function testPropertyPanel()
+{
+  var HUD = HUDService.hudWeakReferences[hudId].get();
+  var jsterm = HUD.jsterm;
+
+  let propPanel = jsterm.openPropertyPanel("Test", [
+    1,
+    /abc/,
+    null,
+    undefined,
+    function test() {},
+    {}
+  ]);
+  is (propPanel.treeView.rowCount, 6, "six elements shown in propertyPanel");
+  propPanel.destroy();
+
+  propPanel = jsterm.openPropertyPanel("Test2", {
+    "0.02": 0,
+    "0.01": 1,
+    "02":   2,
+    "1":    3,
+    "11":   4,
+    "1.2":  5,
+    "1.1":  6,
+    "foo":  7,
+    "bar":  8
+  });
+  is (propPanel.treeView.rowCount, 9, "nine elements shown in propertyPanel");
+
+  let treeRows = propPanel.treeView._rows;
+  is (treeRows[0].display, "0.01: 1", "1. element is okay");
+  is (treeRows[1].display, "0.02: 0", "2. element is okay");
+  is (treeRows[2].display, "1: 3",    "3. element is okay");
+  is (treeRows[3].display, "1.1: 6",  "4. element is okay");
+  is (treeRows[4].display, "1.2: 5",  "5. element is okay");
+  is (treeRows[5].display, "02: 2",   "6. element is okay");
+  is (treeRows[6].display, "11: 4",   "7. element is okay");
+  is (treeRows[7].display, "bar: 8",  "8. element is okay");
+  is (treeRows[8].display, "foo: 7",  "9. element is okay");
+  propPanel.destroy();
+}
+
 function testIteration() {
   var id = "foo";
   var it = cs.displayStore(id);
   var entry = it.next();
   var entry2 = it.next();
 
   let entries = [];
   for (var i = 0; i < 100; i++) {
@@ -873,12 +915,13 @@ function test() {
       testOutputOrder();
       testGroups();
       testNullUndefinedOutput();
       testJSInputAndOutputStyling();
       testExecutionScope();
       testCompletion();
       testPropertyProvider();
       testJSInputExpand();
+      testPropertyPanel();
       testNet();
     });
   }, false);
 }
--- a/toolkit/locales/en-US/chrome/global/headsUpDisplay.properties
+++ b/toolkit/locales/en-US/chrome/global/headsUpDisplay.properties
@@ -31,10 +31,27 @@ tipConsoleWarnings=Log calls to console.
 btnConsoleLog=Log
 tipConsoleLog=Log calls to console.log()
 btnGlobal=Global Messages
 tipGlobal=Toggle Global Message logging
 localConsole=Local Console
 btnClear=Clear Console
 tipClear=Clear the console output
 stringFilter=Filter
+close.button=Close
+close.accesskey=C
+update.button=Update
+update.accesskey=U
+# LOCALIZATION NOTE FOR `jsPropertyTitle` AND `jsPropertyInspectTitle`:
+#
+# The "PropertyPanel" is used to display a JS object to the user.
+# If it is clear which object is being inspected (e.g., window, document object)
+# the title of the panel is based on the `jsPropertyInspectTitle` string.
+# If it isn't clear which object is being inspected, the `jsPropertyTitle` string
+# gets used. This can be the case when the user logs an object to the WebConsole
+# output using the console.log(aObjectToInspect) method.
+#
+# You can find a screenshot of the PropertyPanel here:
+#   https://bug585030.bugzilla.mozilla.org/attachment.cgi?id=464034
+jsPropertyTitle=Object Inspector
+jsPropertyInspectTitle=Inspect: %S
 copyCmd.label=Copy
 copyCmd.accesskey=C