Bug 709223 - GCLI needs a 'resource' type; r=dcamp
authorJoe Walker <jwalker@mozilla.com>
Tue, 07 Feb 2012 10:20:20 +0000
changeset 86420 99d735b9cf8cf9f0c70789f698527c19b6253b49
parent 86419 7afc97477dedd7928c887397479d6d79b9c2ce7b
child 86421 2c5df7e190ce50139b241d360ad454ce57dfe43d
push id94
push userbturner@mozilla.com
push dateWed, 08 Feb 2012 05:39:15 +0000
reviewersdcamp
bugs709223
milestone13.0a1
Bug 709223 - GCLI needs a 'resource' type; r=dcamp
browser/devtools/webconsole/gcli.jsm
browser/devtools/webconsole/test/browser_gcli_web.js
--- a/browser/devtools/webconsole/gcli.jsm
+++ b/browser/devtools/webconsole/gcli.jsm
@@ -686,37 +686,39 @@ var mozl10n = {};
     }
     catch (ex) {
       throw new Error("Failure in lookupFormat('" + name + "')");
     }
   };
 
 })(mozl10n);
 
-define('gcli/index', ['require', 'exports', 'module' , 'gcli/canon', 'gcli/types/basic', 'gcli/types/javascript', 'gcli/types/node', 'gcli/cli', 'gcli/commands/help', 'gcli/ui/console'], function(require, exports, module) {
+define('gcli/index', ['require', 'exports', 'module' , 'gcli/canon', 'gcli/types/basic', 'gcli/types/javascript', 'gcli/types/node', 'gcli/types/resource', 'gcli/cli', 'gcli/commands/help', 'gcli/ui/console'], function(require, exports, module) {
 
   // The API for use by command authors
   exports.addCommand = require('gcli/canon').addCommand;
   exports.removeCommand = require('gcli/canon').removeCommand;
   exports.lookup = mozl10n.lookup;
   exports.lookupFormat = mozl10n.lookupFormat;
 
   // Internal startup process. Not exported
   require('gcli/types/basic').startup();
   require('gcli/types/javascript').startup();
   require('gcli/types/node').startup();
+  require('gcli/types/resource').startup();
   require('gcli/cli').startup();
   require('gcli/commands/help').startup();
 
   var Requisition = require('gcli/cli').Requisition;
   var Console = require('gcli/ui/console').Console;
 
   var cli = require('gcli/cli');
   var jstype = require('gcli/types/javascript');
   var nodetype = require('gcli/types/node');
+  var resource = require('gcli/types/resource');
 
   /**
    * API for use by HUDService only.
    * This code is internal and subject to change without notice.
    */
   exports._internal = {
     require: require,
     define: define,
@@ -735,16 +737,17 @@ define('gcli/index', ['require', 'export
      * - gcliTerm: GCLITerm
      * - hintElement: GCLITerm.hintNode
      * - inputBackgroundElement: GCLITerm.inputStack
      */
     createView: function(opts) {
       jstype.setGlobalObject(opts.jsEnvironment.globalObject);
       nodetype.setDocument(opts.contentDocument);
       cli.setEvalFunction(opts.jsEnvironment.evalFunction);
+      resource.setDocument(opts.contentDocument);
 
       if (opts.requisition == null) {
         opts.requisition = new Requisition(opts.environment, opts.chromeDocument);
       }
 
       opts.console = new Console(opts);
     },
 
@@ -756,16 +759,18 @@ define('gcli/index', ['require', 'export
       delete opts.console;
 
       opts.requisition.destroy();
       delete opts.requisition;
 
       cli.unsetEvalFunction();
       nodetype.unsetDocument();
       jstype.unsetGlobalObject();
+      resource.unsetDocument();
+      resource.clearResourceCache();
     },
 
     commandOutputManager: require('gcli/canon').commandOutputManager
   };
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
@@ -1302,16 +1307,89 @@ dom.isXmlDocument = function(doc) {
   if (doc.xmlVersion != null) {
     return true;
   }
   return false;
 };
 
 exports.dom = dom;
 
+/**
+ * Find the position of [element] in [nodeList].
+ * @returns an index of the match, or -1 if there is no match
+ */
+function positionInNodeList(element, nodeList) {
+  for (var i = 0; i < nodeList.length; i++) {
+    if (element === nodeList[i]) {
+      return i;
+    }
+  }
+  return -1;
+}
+
+/**
+ * Find a unique CSS selector for a given element
+ * @returns a string such that ele.ownerDocument.querySelector(reply) === ele
+ * and ele.ownerDocument.querySelectorAll(reply).length === 1
+ */
+dom.findCssSelector = function(ele) {
+  var document = ele.ownerDocument;
+  if (ele.id && document.getElementById(ele.id) === ele) {
+    return '#' + ele.id;
+  }
+
+  // Inherently unique by tag name
+  var tagName = ele.tagName.toLowerCase();
+  if (tagName === 'html') {
+    return 'html';
+  }
+  if (tagName === 'head') {
+    return 'head';
+  }
+  if (tagName === 'body') {
+    return 'body';
+  }
+
+  if (ele.parentNode == null) {
+    console.log('danger: ' + tagName);
+  }
+
+  // We might be able to find a unique class name
+  var selector, index, matches;
+  if (ele.classList.length > 0) {
+    for (var i = 0; i < ele.classList.length; i++) {
+      // Is this className unique by itself?
+      selector = '.' + ele.classList.item(i);
+      matches = document.querySelectorAll(selector);
+      if (matches.length === 1) {
+        return selector;
+      }
+      // Maybe it's unique with a tag name?
+      selector = tagName + selector;
+      matches = document.querySelectorAll(selector);
+      if (matches.length === 1) {
+        return selector;
+      }
+      // Maybe it's unique using a tag name and nth-child
+      index = positionInNodeList(ele, ele.parentNode.children) + 1;
+      selector = selector + ':nth-child(' + index + ')';
+      matches = document.querySelectorAll(selector);
+      if (matches.length === 1) {
+        return selector;
+      }
+    }
+  }
+
+  // So we can be unique w.r.t. our parent, and use recursion
+  index = positionInNodeList(ele, ele.parentNode.children) + 1;
+  selector = dom.findCssSelector(ele.parentNode) + ' > ' +
+          tagName + ':nth-child(' + index + ')';
+
+  return selector;
+};
 
 //------------------------------------------------------------------------------
 
 /**
  * Various event utilities
  */
 var event = {};
 
@@ -1938,17 +2016,17 @@ types.deregisterType = function(type) {
 /**
  * Find a type, previously registered using #registerType()
  */
 types.getType = function(typeSpec) {
   var type;
   if (typeof typeSpec === 'string') {
     type = registeredTypes[typeSpec];
     if (typeof type === 'function') {
-      type = new type();
+      type = new type({});
     }
     return type;
   }
 
   if (typeof typeSpec === 'object') {
     if (!typeSpec.name) {
       throw new Error('Missing \'name\' member to typeSpec');
     }
@@ -2462,17 +2540,17 @@ exports.shutdown = function() {
   types.unregisterType(ArrayType);
 };
 
 
 /**
  * 'string' the most basic string type that doesn't need to convert
  */
 function StringType(typeSpec) {
-  if (typeSpec != null) {
+  if (Object.keys(typeSpec).length > 0) {
     throw new Error('StringType can not be customized');
   }
 }
 
 StringType.prototype = Object.create(Type.prototype);
 
 StringType.prototype.stringify = function(value) {
   if (value == null) {
@@ -2633,17 +2711,17 @@ SelectionType.prototype.stringify = func
     return false;
   }, this);
   return name;
 };
 
 /**
  * There are several ways to get selection data. This unifies them into one
  * single function.
- * @return A map of names to values.
+ * @return An array of objects with name and value properties.
  */
 SelectionType.prototype.getLookup = function() {
   if (this.lookup) {
     if (typeof this.lookup === 'function') {
       return this.lookup();
     }
     return this.lookup;
   }
@@ -2686,43 +2764,33 @@ SelectionType.prototype._findPredictions
     }
   }, this);
   return predictions;
 };
 
 SelectionType.prototype.parse = function(arg) {
   var predictions = this._findPredictions(arg);
 
-  if (predictions.length === 1 && predictions[0].name === arg.text) {
-    var value = predictions[0].value ? predictions[0].value : predictions[0];
-    return new Conversion(value, arg);
-  }
-
   // This is something of a hack it basically allows us to tell the
   // setting type to forget its last setting hack.
   if (this.noMatch) {
     this.noMatch();
   }
 
   if (predictions.length > 0) {
-    // Especially at startup, predictions live over the time that things
-    // change so we provide a completion function rather than completion
-    // values.
-    // This was primarily designed for commands, which have since moved
-    // into their own type, so technically we could remove this code,
-    // except that it provides more up-to-date answers, and it's hard to
-    // predict when it will be required.
-    var predictFunc = function() {
-      return this._findPredictions(arg);
-    }.bind(this);
-    return new Conversion(null, arg, Status.INCOMPLETE, '', predictFunc);
-  }
-
-  return new Conversion(null, arg, Status.ERROR,
-      l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]));
+    if (predictions[0].name === arg.text) {
+      var value = predictions[0].value ? predictions[0].value : predictions[0];
+      return new Conversion(value, arg, Status.VALID, '', predictions);
+    }
+
+    return new Conversion(null, arg, Status.INCOMPLETE, '', predictions);
+  }
+
+  var msg = l10n.lookupFormat('typesSelectionNomatch', [ arg.text ]);
+  return new Conversion(null, arg, Status.ERROR, msg, predictions);
 };
 
 /**
  * For selections, up is down and black is white. It's like this, given a list
  * [ a, b, c, d ], it's natural to think that it starts at the top and that
  * going up the list, moves towards 'a'. However 'a' has the lowest index, so
  * for SelectionType, up is down and down is up.
  * Sorry.
@@ -2783,17 +2851,17 @@ SelectionType.prototype.name = 'selectio
 
 exports.SelectionType = SelectionType;
 
 
 /**
  * true/false values
  */
 function BooleanType(typeSpec) {
-  if (typeSpec != null) {
+  if (Object.keys(typeSpec).length > 0) {
     throw new Error('BooleanType can not be customized');
   }
 }
 
 BooleanType.prototype = Object.create(SelectionType.prototype);
 
 BooleanType.prototype.lookup = [
   { name: 'true', value: true },
@@ -2865,17 +2933,17 @@ DeferredType.prototype.name = 'deferred'
 exports.DeferredType = DeferredType;
 
 
 /**
  * 'blank' is a type for use with DeferredType when we don't know yet.
  * It should not be used anywhere else.
  */
 function BlankType(typeSpec) {
-  if (typeSpec != null) {
+  if (Object.keys(typeSpec).length > 0) {
     throw new Error('BlankType can not be customized');
   }
 }
 
 BlankType.prototype = Object.create(Type.prototype);
 
 BlankType.prototype.stringify = function(value) {
   return '';
@@ -3000,17 +3068,17 @@ exports.unsetGlobalObject = function() {
   globalObject = undefined;
 };
 
 
 /**
  * 'javascript' handles scripted input
  */
 function JavascriptType(typeSpec) {
-  if (typeSpec != null) {
+  if (Object.keys(typeSpec).length > 0) {
     throw new Error('JavascriptType can not be customized');
   }
 }
 
 JavascriptType.prototype = Object.create(Type.prototype);
 
 JavascriptType.prototype.stringify = function(value) {
   if (value == null) {
@@ -3550,17 +3618,17 @@ exports.getDocument = function() {
   return doc;
 };
 
 
 /**
  * A CSS expression that refers to a single node
  */
 function NodeType(typeSpec) {
-  if (typeSpec != null) {
+  if (Object.keys(typeSpec).length > 0) {
     throw new Error('NodeType can not be customized');
   }
 }
 
 NodeType.prototype = Object.create(Type.prototype);
 
 NodeType.prototype.stringify = function(value) {
   return value.__gcliQuery || 'Error';
@@ -3628,16 +3696,317 @@ exports.flashNode = function(node, color
 
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
+define('gcli/types/resource', ['require', 'exports', 'module' , 'gcli/host', 'gcli/l10n', 'gcli/types', 'gcli/types/basic'], function(require, exports, module) {
+
+
+var host = require('gcli/host');
+var l10n = require('gcli/l10n');
+var types = require('gcli/types');
+var SelectionType = require('gcli/types/basic').SelectionType;
+var Status = require('gcli/types').Status;
+var Conversion = require('gcli/types').Conversion;
+
+
+/**
+ * Registration and de-registration.
+ */
+exports.startup = function() {
+  types.registerType(ResourceType);
+};
+
+exports.shutdown = function() {
+  types.unregisterType(ResourceType);
+  exports.clearResourceCache();
+};
+
+exports.clearResourceCache = function() {
+  ResourceCache.clear();
+};
+
+/**
+ * The object against which we complete, which is usually 'window' if it exists
+ * but could be something else in non-web-content environments.
+ */
+var doc;
+if (typeof document !== 'undefined') {
+  doc = document;
+}
+
+/**
+ * Setter for the document that contains the nodes we're matching
+ */
+exports.setDocument = function(document) {
+  doc = document;
+};
+
+/**
+ * Undo the effects of setDocument()
+ */
+exports.unsetDocument = function() {
+  doc = undefined;
+};
+
+/**
+ * Getter for the document that contains the nodes we're matching
+ * Most for changing things back to how they were for unit testing
+ */
+exports.getDocument = function() {
+  return doc;
+};
+
+/**
+ * Resources are bits of CSS and JavaScript that the page either includes
+ * directly or as a result of reading some remote resource.
+ * Resource should not be used directly, but instead through a sub-class like
+ * CssResource or ScriptResource.
+ */
+function Resource(id, name, type, inline, element) {
+  this.id = id;
+  this.name = name;
+  this.type = type;
+  this.inline = inline;
+  this.element = element;
+}
+
+/**
+ * Get the contents of the given resource as a string.
+ * The base Resource leaves this unimplemented.
+ */
+Resource.prototype.getContents = function() {
+  throw new Error('not implemented');
+};
+
+Resource.TYPE_SCRIPT = 'text/javascript';
+Resource.TYPE_CSS = 'text/css';
+
+/**
+ * A CssResource provides an implementation of Resource that works for both
+ * [style] elements and [link type='text/css'] elements in the [head].
+ */
+function CssResource(domSheet) {
+  this.name = domSheet.href;
+  if (!this.name) {
+    this.name = domSheet.ownerNode.id ?
+            'css#' + domSheet.ownerNode.id :
+            'inline-css';
+  }
+
+  this.inline = (domSheet.href == null);
+  this.type = Resource.TYPE_CSS;
+  this.element = domSheet;
+}
+
+CssResource.prototype = Object.create(Resource.prototype);
+
+CssResource.prototype.loadContents = function(callback) {
+  callback(this.element.ownerNode.innerHTML);
+};
+
+CssResource._getAllStyles = function() {
+  var resources = [];
+  Array.prototype.forEach.call(doc.styleSheets, function(domSheet) {
+    CssResource._getStyle(domSheet, resources);
+  });
+
+  dedupe(resources, function(clones) {
+    for (var i = 0; i < clones.length; i++) {
+      clones[i].name = clones[i].name + '-' + i;
+    }
+  });
+
+  return resources;
+};
+
+CssResource._getStyle = function(domSheet, resources) {
+  var resource = ResourceCache.get(domSheet);
+  if (!resource) {
+    resource = new CssResource(domSheet);
+    ResourceCache.add(domSheet, resource);
+  }
+  resources.push(resource);
+
+  // Look for imported stylesheets
+  try {
+    Array.prototype.forEach.call(domSheet.cssRules, function(domRule) {
+      if (domRule.type == CSSRule.IMPORT_RULE && domRule.styleSheet) {
+        CssResource._getStyle(domRule.styleSheet, resources);
+      }
+    }, this);
+  }
+  catch (ex) {
+    // For system stylesheets
+  }
+};
+
+/**
+ * A ScriptResource provides an implementation of Resource that works for
+ * [script] elements (both with a src attribute, and used directly).
+ */
+function ScriptResource(scriptNode) {
+  this.name = scriptNode.src;
+  if (!this.name) {
+    this.name = scriptNode.id ?
+            'script#' + scriptNode.id :
+            'inline-script';
+  }
+
+  this.inline = (scriptNode.src === '' || scriptNode.src == null);
+  this.type = Resource.TYPE_SCRIPT;
+  this.element = scriptNode;
+}
+
+ScriptResource.prototype = Object.create(Resource.prototype);
+
+ScriptResource.prototype.loadContents = function(callback) {
+  if (this.inline) {
+    callback(this.element.innerHTML);
+  }
+  else {
+    // It would be good if there was a better way to get the script source
+    var xhr = new XMLHttpRequest();
+    xhr.onreadystatechange = function() {
+      if (xhr.readyState !== xhr.DONE) {
+        return;
+      }
+      callback(xhr.responseText);
+    };
+    xhr.open('GET', this.element.src, true);
+    xhr.send();
+  }
+};
+
+ScriptResource._getAllScripts = function() {
+  var scriptNodes = doc.querySelectorAll('script');
+  var resources = Array.prototype.map.call(scriptNodes, function(scriptNode) {
+    var resource = ResourceCache.get(scriptNode);
+    if (!resource) {
+      resource = new ScriptResource(scriptNode);
+      ResourceCache.add(scriptNode, resource);
+    }
+    return resource;
+  });
+
+  dedupe(resources, function(clones) {
+    for (var i = 0; i < clones.length; i++) {
+      clones[i].name = clones[i].name + '-' + i;
+    }
+  });
+
+  return resources;
+};
+
+/**
+ * Find resources with the same name, and call onDupe to change the names
+ */
+function dedupe(resources, onDupe) {
+  // first create a map of name->[array of resources with same name]
+  var names = {};
+  resources.forEach(function(scriptResource) {
+    if (names[scriptResource.name] == null) {
+      names[scriptResource.name] = [];
+    }
+    names[scriptResource.name].push(scriptResource);
+  });
+
+  // Call the de-dupe function for each set of dupes
+  Object.keys(names).forEach(function(name) {
+    var clones = names[name];
+    if (clones.length > 1) {
+      onDupe(clones);
+    }
+  });
+}
+
+/**
+ * A CSS expression that refers to a single node
+ */
+function ResourceType(typeSpec) {
+  this.include = typeSpec.include;
+  if (this.include !== Resource.TYPE_SCRIPT &&
+      this.include !== Resource.TYPE_CSS &&
+      this.include != null) {
+    throw new Error('invalid include property: ' + this.include);
+  }
+}
+
+ResourceType.prototype = Object.create(SelectionType.prototype);
+
+/**
+ * There are several ways to get selection data. This unifies them into one
+ * single function.
+ * @return A map of names to values.
+ */
+ResourceType.prototype.getLookup = function() {
+  var resources = [];
+  if (this.include !== Resource.TYPE_SCRIPT) {
+    Array.prototype.push.apply(resources, CssResource._getAllStyles());
+  }
+  if (this.include !== Resource.TYPE_CSS) {
+    Array.prototype.push.apply(resources, ScriptResource._getAllScripts());
+  }
+
+  return resources.map(function(resource) {
+    return { name: resource.name, value: resource };
+  });
+};
+
+ResourceType.prototype.name = 'resource';
+
+
+/**
+ * A quick cache of resources against nodes
+ * TODO: Potential memory leak when the target document has css or script
+ * resources repeatedly added and removed. Solution might be to use a weak
+ * hash map or some such.
+ */
+var ResourceCache = {
+  _cached: [],
+
+  /**
+   * Do we already have a resource that was created for the given node
+   */
+  get: function(node) {
+    for (var i = 0; i < ResourceCache._cached.length; i++) {
+      if (ResourceCache._cached[i].node === node) {
+        return ResourceCache._cached[i].resource;
+      }
+    }
+    return null;
+  },
+
+  /**
+   * Add a resource for a given node
+   */
+  add: function(node, resource) {
+    ResourceCache._cached.push({ node: node, resource: resource });
+  },
+
+  /**
+   * Drop all cache entries. Helpful to prevent memory leaks
+   */
+  clear: function() {
+    ResourceCache._cached = {};
+  }
+};
+
+
+});
+/*
+ * Copyright 2009-2011 Mozilla Foundation and contributors
+ * Licensed under the New BSD license. See LICENSE.txt or:
+ * http://opensource.org/licenses/BSD-3-Clause
+ */
+
 define('gcli/cli', ['require', 'exports', 'module' , 'gcli/util', 'gcli/canon', 'gcli/promise', 'gcli/types', 'gcli/types/basic', 'gcli/argument'], function(require, exports, module) {
 
 
 var util = require('gcli/util');
 
 var canon = require('gcli/canon');
 var Promise = require('gcli/promise').Promise;
 
--- a/browser/devtools/webconsole/test/browser_gcli_web.js
+++ b/browser/devtools/webconsole/test/browser_gcli_web.js
@@ -49,63 +49,35 @@ var define = obj.gcli._internal.define;
 var console = obj.gcli._internal.console;
 var Node = Components.interfaces.nsIDOMNode;
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
-define('gclitest/index', ['require', 'exports', 'module' , 'gclitest/suite', 'gcli/types/javascript'], function(require, exports, module) {
+define('gclitest/index', ['require', 'exports', 'module' , 'gclitest/suite'], function(require, exports, module) {
 
   var examiner = require('gclitest/suite').examiner;
-  var javascript = require('gcli/types/javascript');
 
   /**
-   * Run the tests defined in the test suite
-   * @param options How the tests are run. Properties include:
-   * - window: The browser window object to run the tests against
-   * - useFakeWindow: Use a test subset and a fake DOM to avoid a real document
-   * - detailedResultLog: console.log test passes and failures in more detail
+   * A simple proxy to examiner.run, for convenience - this is run from the
+   * top level.
    */
   exports.run = function(options) {
-    options = options || {};
-
-    if (options.useFakeWindow) {
-      // A minimum fake dom to get us through the JS tests
-      var doc = { title: 'Fake DOM' };
-      var fakeWindow = {
-        window: { document: doc },
-        document: doc
-      };
-
-      options.window = fakeWindow;
-    }
-
-    if (options.window) {
-      javascript.setGlobalObject(options.window);
-    }
-
-    examiner.run(options);
-
-    if (options.detailedResultLog) {
-      examiner.log();
-    }
-    else {
-      console.log('Completed test suite');
-    }
+    examiner.run(options || {});
   };
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
-define('gclitest/suite', ['require', 'exports', 'module' , 'gcli/index', 'test/examiner', 'gclitest/testTokenize', 'gclitest/testSplit', 'gclitest/testCli', 'gclitest/testExec', 'gclitest/testKeyboard', 'gclitest/testScratchpad', 'gclitest/testHistory', 'gclitest/testRequire', 'gclitest/testJs'], function(require, exports, module) {
+define('gclitest/suite', ['require', 'exports', 'module' , 'gcli/index', 'test/examiner', 'gclitest/testTokenize', 'gclitest/testSplit', 'gclitest/testCli', 'gclitest/testExec', 'gclitest/testKeyboard', 'gclitest/testScratchpad', 'gclitest/testHistory', 'gclitest/testRequire', 'gclitest/testResource', 'gclitest/testJs', 'gclitest/testUtil'], function(require, exports, module) {
 
   // We need to make sure GCLI is initialized before we begin testing it
   require('gcli/index');
 
   var examiner = require('test/examiner');
 
   // It's tempting to want to unify these strings and make addSuite() do the
   // call to require(), however that breaks the build system which looks for
@@ -113,17 +85,19 @@ define('gclitest/suite', ['require', 'ex
   examiner.addSuite('gclitest/testTokenize', require('gclitest/testTokenize'));
   examiner.addSuite('gclitest/testSplit', require('gclitest/testSplit'));
   examiner.addSuite('gclitest/testCli', require('gclitest/testCli'));
   examiner.addSuite('gclitest/testExec', require('gclitest/testExec'));
   examiner.addSuite('gclitest/testKeyboard', require('gclitest/testKeyboard'));
   examiner.addSuite('gclitest/testScratchpad', require('gclitest/testScratchpad'));
   examiner.addSuite('gclitest/testHistory', require('gclitest/testHistory'));
   examiner.addSuite('gclitest/testRequire', require('gclitest/testRequire'));
+  examiner.addSuite('gclitest/testResource', require('gclitest/testResource'));
   examiner.addSuite('gclitest/testJs', require('gclitest/testJs'));
+  examiner.addSuite('gclitest/testUtil', require('gclitest/testUtil'));
 
   exports.examiner = examiner;
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
@@ -156,30 +130,66 @@ var stati = {
  * Add a test suite. Generally used like:
  * test.addSuite('foo', require('path/to/foo'));
  */
 examiner.addSuite = function(name, suite) {
   examiner.suites[name] = new Suite(name, suite);
 };
 
 /**
- * Run all the tests synchronously
+ * Run the tests defined in the test suite synchronously
+ * @param options How the tests are run. Properties include:
+ * - window: The browser window object to run the tests against
+ * - useFakeWindow: Use a test subset and a fake DOM to avoid a real document
+ * - detailedResultLog: console.log test passes and failures in more detail
  */
 examiner.run = function(options) {
+  examiner._checkOptions(options);
+
   Object.keys(examiner.suites).forEach(function(suiteName) {
     var suite = examiner.suites[suiteName];
     suite.run(options);
   }.bind(this));
+
+  if (options.detailedResultLog) {
+    examiner.log();
+  }
+  else {
+    console.log('Completed test suite');
+  }
+
   return examiner.suites;
 };
 
 /**
+ * Check the options object. There should be either useFakeWindow or a window.
+ * Setup the fake window if requested.
+ */
+examiner._checkOptions = function(options) {
+  if (options.useFakeWindow) {
+    // A minimum fake dom to get us through the JS tests
+    var doc = { title: 'Fake DOM' };
+    var fakeWindow = {
+      window: { document: doc },
+      document: doc
+    };
+
+    options.window = fakeWindow;
+  }
+
+  if (!options.window) {
+    throw new Error('Tests need either window or useFakeWindow');
+  }
+};
+
+/**
  * Run all the tests asynchronously
  */
 examiner.runAsync = function(options, callback) {
+  examiner._checkOptions(options);
   this.runAsyncInternal(0, options, callback);
 };
 
 /**
  * Run all the test suits asynchronously
  */
 examiner.runAsyncInternal = function(i, options, callback) {
   if (i >= Object.keys(examiner.suites).length) {
@@ -281,22 +291,22 @@ Suite.prototype.run = function(options) 
   }
 };
 
 /**
  * Run all the tests in this suite asynchronously
  */
 Suite.prototype.runAsync = function(options, callback) {
   if (typeof this.suite.setup == "function") {
-    this.suite.setup();
+    this.suite.setup(options);
   }
 
   this.runAsyncInternal(0, options, function() {
     if (typeof this.suite.shutdown == "function") {
-      this.suite.shutdown();
+      this.suite.shutdown(options);
     }
 
     if (typeof callback === 'function') {
       callback();
     }
   }.bind(this));
 };
 
@@ -372,17 +382,17 @@ Test.prototype.run = function(options) {
   currentTest = null;
 };
 
 /**
  * Run all the tests in this suite asynchronously
  */
 Test.prototype.runAsync = function(options, callback) {
   setTimeout(function() {
-    this.run();
+    this.run(options);
     if (typeof callback === 'function') {
       callback();
     }
   }.bind(this), delay);
 };
 
 /**
  * Create a JSON object suitable for serialization
@@ -1501,34 +1511,42 @@ var mockDoc = {
 
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
-define('gclitest/testKeyboard', ['require', 'exports', 'module' , 'gcli/cli', 'gcli/types', 'gcli/canon', 'gclitest/commands', 'gcli/types/node', 'test/assert'], function(require, exports, module) {
+define('gclitest/testKeyboard', ['require', 'exports', 'module' , 'gcli/cli', 'gcli/types', 'gcli/canon', 'gclitest/commands', 'gcli/types/node', 'gcli/types/javascript', 'test/assert'], function(require, exports, module) {
 
 
 var Requisition = require('gcli/cli').Requisition;
 var Status = require('gcli/types').Status;
 var canon = require('gcli/canon');
 var commands = require('gclitest/commands');
 var nodetype = require('gcli/types/node');
+var javascript = require('gcli/types/javascript');
 
 var test = require('test/assert');
 
+var tempWindow;
 
-exports.setup = function() {
+exports.setup = function(options) {
+  tempWindow = javascript.getGlobalObject();
+  javascript.setGlobalObject(options.window);
+
   commands.setup();
 };
 
-exports.shutdown = function() {
+exports.shutdown = function(options) {
   commands.shutdown();
+
+  javascript.setGlobalObject(tempWindow);
+  tempWindow = undefined;
 };
 
 var COMPLETES_TO = 'complete';
 var KEY_UPS_TO = 'keyup';
 var KEY_DOWNS_TO = 'keydown';
 
 function check(initial, action, after) {
   var requisition = new Requisition();
@@ -1656,25 +1674,25 @@ var stubScratchpad = {
 };
 stubScratchpad.activate = function(value) {
   stubScratchpad.activatedCount++;
   return true;
 };
 
 
 exports.testActivate = function(options) {
-  if (options.inputter) {
-    var ev = {};
-    stubScratchpad.activatedCount = 0;
-    options.inputter.onKeyUp(ev);
-    test.is(1, stubScratchpad.activatedCount, 'scratchpad is activated');
+  if (!options.inputter) {
+    console.log('No inputter. Skipping scratchpad tests');
+    return;
   }
-  else {
-    console.log('Skipping scratchpad tests');
-  }
+
+  var ev = {};
+  stubScratchpad.activatedCount = 0;
+  options.inputter.onKeyUp(ev);
+  test.is(1, stubScratchpad.activatedCount, 'scratchpad is activated');
 };
 
 
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
@@ -1839,48 +1857,133 @@ define('gclitest/requirable', ['require'
 
 });
 /*
  * Copyright 2009-2011 Mozilla Foundation and contributors
  * Licensed under the New BSD license. See LICENSE.txt or:
  * http://opensource.org/licenses/BSD-3-Clause
  */
 
+define('gclitest/testResource', ['require', 'exports', 'module' , 'gcli/types/resource', 'gcli/types', 'test/assert'], function(require, exports, module) {
+
+
+var resource = require('gcli/types/resource');
+var types = require('gcli/types');
+var Status = require('gcli/types').Status;
+
+var test = require('test/assert');
+
+var tempDocument;
+
+exports.setup = function(options) {
+  tempDocument = resource.getDocument();
+  resource.setDocument(options.window.document);
+};
+
+exports.shutdown = function(options) {
+  resource.setDocument(tempDocument);
+  tempDocument = undefined;
+};
+
+exports.testPredictions = function(options) {
+  if (options.useFakeWindow) {
+    console.log('Skipping resource tests: options.useFakeWindow = true');
+    return;
+  }
+
+  var resource1 = types.getType('resource');
+  var predictions1 = resource1.parseString('').getPredictions();
+  test.ok(predictions1.length > 1, 'have resources');
+  predictions1.forEach(function(prediction) {
+    checkPrediction(resource1, prediction);
+  });
+
+  var resource2 = types.getType({ name: 'resource', include: 'text/javascript' });
+  var predictions2 = resource2.parseString('').getPredictions();
+  test.ok(predictions2.length > 1, 'have resources');
+  predictions2.forEach(function(prediction) {
+    checkPrediction(resource2, prediction);
+  });
+
+  var resource3 = types.getType({ name: 'resource', include: 'text/css' });
+  var predictions3 = resource3.parseString('').getPredictions();
+  // jsdom fails to support digging into stylesheets
+  if (!options.isNode) {
+    test.ok(predictions3.length > 1, 'have resources');
+  }
+  predictions3.forEach(function(prediction) {
+    checkPrediction(resource3, prediction);
+  });
+
+  var resource4 = types.getType({ name: 'resource' });
+  var predictions4 = resource4.parseString('').getPredictions();
+
+  test.is(predictions1.length, predictions4.length, 'type spec');
+  test.is(predictions2.length + predictions3.length, predictions4.length, 'split');
+};
+
+function checkPrediction(res, prediction) {
+  var name = prediction.name;
+  var value = prediction.value;
+
+  var conversion = res.parseString(name);
+  test.is(conversion.getStatus(), Status.VALID, 'status VALID for ' + name);
+  test.is(conversion.value, value, 'value for ' + name);
+
+  var strung = res.stringify(value);
+  test.is(strung, name, 'stringify for ' + name);
+
+  test.is(typeof value.loadContents, 'function', 'resource for ' + name);
+  test.is(typeof value.element, 'object', 'resource for ' + name);
+}
+
+});
+/*
+ * Copyright 2009-2011 Mozilla Foundation and contributors
+ * Licensed under the New BSD license. See LICENSE.txt or:
+ * http://opensource.org/licenses/BSD-3-Clause
+ */
+
 define('gclitest/testJs', ['require', 'exports', 'module' , 'gcli/cli', 'gcli/types', 'gcli/types/javascript', 'test/assert'], function(require, exports, module) {
 
 
 var Requisition = require('gcli/cli').Requisition;
 var Status = require('gcli/types').Status;
 var javascript = require('gcli/types/javascript');
 
 var test = require('test/assert');
 
 var debug = false;
 var requ;
 
 var assign;
 var status;
 var statuses;
-var globalObject;
+var tempWindow;
+
 
-exports.setup = function() {
-  globalObject = javascript.getGlobalObject();
-  Object.defineProperty(globalObject, 'donteval', {
+exports.setup = function(options) {
+  tempWindow = javascript.getGlobalObject();
+  javascript.setGlobalObject(options.window);
+
+  Object.defineProperty(options.window, 'donteval', {
     get: function() {
       test.ok(false, 'donteval should not be used');
       return { cant: '', touch: '', 'this': '' };
     },
     enumerable: true,
     configurable : true
   });
 };
 
-exports.shutdown = function() {
-  delete globalObject.donteval;
-  globalObject = undefined;
+exports.shutdown = function(options) {
+  delete options.window.donteval;
+
+  javascript.setGlobalObject(tempWindow);
+  tempWindow = undefined;
 };
 
 function input(typed) {
   if (!requ) {
     requ = new Requisition();
   }
   var cursor = { start: typed.length, end: typed.length };
   var input = { typed: typed, cursor: cursor };
@@ -1943,17 +2046,17 @@ function check(expStatuses, expStatus, e
     if (!contains) {
       console.log('Predictions: ' + assign.getPredictions().map(function(p) {
         return p.name;
       }).join(', '));
     }
   }
 }
 
-exports.testBasic = function() {
+exports.testBasic = function(options) {
   input('{');
   check('V', Status.ERROR, '');
 
   input('{ ');
   check('VV', Status.ERROR, '');
 
   input('{ w');
   check('VVI', Status.ERROR, 'w', 'window');
@@ -1971,17 +2074,17 @@ exports.testBasic = function() {
   check('VVVVVVVVVVVVVVVVVVVVVVV', Status.VALID, 'window.document.title', 0);
 
   input('{ d');
   check('VVI', Status.ERROR, 'd', 'document');
 
   input('{ document.title');
   check('VVVVVVVVVVVVVVVV', Status.VALID, 'document.title', 0);
 
-  test.ok('donteval' in globalObject, 'donteval exists');
+  test.ok('donteval' in options.window, 'donteval exists');
 
   input('{ don');
   check('VVIII', Status.ERROR, 'don', 'donteval');
 
   input('{ donteval');
   check('VVVVVVVVVV', Status.VALID, 'donteval', 0);
 
   /*
@@ -1997,49 +2100,82 @@ exports.testBasic = function() {
   check('VVVVVVVVVVVVVVV', Status.VALID, 'donteval.cant', 0);
 
   input('{ donteval.xxx');
   check('VVVVVVVVVVVVVV', Status.VALID, 'donteval.xxx', 0);
 };
 
 
 });
+/*
+ * Copyright 2009-2011 Mozilla Foundation and contributors
+ * Licensed under the New BSD license. See LICENSE.txt or:
+ * http://opensource.org/licenses/BSD-3-Clause
+ */
+
+define('gclitest/testUtil', ['require', 'exports', 'module' , 'gcli/util', 'test/assert'], function(require, exports, module) {
+
+var util = require('gcli/util');
+var test = require('test/assert');
+
+exports.testFindCssSelector = function(options) {
+  if (options.useFakeWindow) {
+    console.log('Skipping dom.findCssSelector tests due to useFakeWindow');
+    return;
+  }
+
+  var nodes = options.window.document.querySelectorAll('*');
+  for (var i = 0; i < nodes.length; i++) {
+    var selector = util.dom.findCssSelector(nodes[i]);
+    var matches = options.window.document.querySelectorAll(selector);
+
+    test.is(matches.length, 1, 'multiple matches for ' + selector);
+    test.is(matches[0], nodes[i], 'non-matching selector: ' + selector);
+  }
+};
+
+
+});
 
 function undefine() {
   delete define.modules['gclitest/index'];
   delete define.modules['gclitest/suite'];
   delete define.modules['test/examiner'];
   delete define.modules['gclitest/testTokenize'];
   delete define.modules['test/assert'];
   delete define.modules['gclitest/testSplit'];
   delete define.modules['gclitest/commands'];
   delete define.modules['gclitest/testCli'];
   delete define.modules['gclitest/testExec'];
   delete define.modules['gclitest/testKeyboard'];
   delete define.modules['gclitest/testScratchpad'];
   delete define.modules['gclitest/testHistory'];
   delete define.modules['gclitest/testRequire'];
   delete define.modules['gclitest/requirable'];
+  delete define.modules['gclitest/testResource'];
   delete define.modules['gclitest/testJs'];
+  delete define.modules['gclitest/testUtil'];
 
   delete define.globalDomain.modules['gclitest/index'];
   delete define.globalDomain.modules['gclitest/suite'];
   delete define.globalDomain.modules['test/examiner'];
   delete define.globalDomain.modules['gclitest/testTokenize'];
   delete define.globalDomain.modules['test/assert'];
   delete define.globalDomain.modules['gclitest/testSplit'];
   delete define.globalDomain.modules['gclitest/commands'];
   delete define.globalDomain.modules['gclitest/testCli'];
   delete define.globalDomain.modules['gclitest/testExec'];
   delete define.globalDomain.modules['gclitest/testKeyboard'];
   delete define.globalDomain.modules['gclitest/testScratchpad'];
   delete define.globalDomain.modules['gclitest/testHistory'];
   delete define.globalDomain.modules['gclitest/testRequire'];
   delete define.globalDomain.modules['gclitest/requirable'];
+  delete define.globalDomain.modules['gclitest/testResource'];
   delete define.globalDomain.modules['gclitest/testJs'];
+  delete define.globalDomain.modules['gclitest/testUtil'];
 }
 
 registerCleanupFunction(function() {
   Services.prefs.clearUserPref("devtools.gcli.enable");
   undefine();
   obj = undefined;
   define = undefined;
   console = undefined;