Let's Make a Framework: jsdom

04 Aug 2011 | By Alex Young | Tags frameworks tutorials lmaf npm

Let’s Make a Framework is an ongoing series about building a JavaScript framework from the ground up.

These articles are tagged with lmaf. The project we’re creating is called Turing. Documentation is available at turingjs.com.

Last week we adapted Turing to load appropriate modules using npm. It’s also possible to adapt the DOM-related code to work using jsdom.

jsdom

jsdom by Elijah Insua is a CommonJS implementation of the DOM. This isn’t a trivial module, and thankfully is extremely easy to use thanks to the recently introduced jsdom.env() method.

There are several different ways to invoke env, but I like passing an object of options:

jsdom.env({
  html: '<p>Some HTML, it could be read from a file.</p>',
  src: [ fileName1, fileName2 ],
  done: function(err, window) {
    if (err) throw(err);
    fn(window.turing);
  }
});

The done callback will be run when the source files have been read. The callback takes two arguments, errors and window. The window object is what we’re looking for.

Adding the Module

The package.json file must be edited to include the new dependency:

// ...
, "dependencies": {
    "jsdom": "0.2.x"
  }

I’ve set it to 0.2.x because I expect the API won’t be too volatile within this version range.

Using jsdom

I’d have liked to be able to use turing('<p>html</p>'), but I reasoned that this was confusing given that this function is effectively overloaded. I decided using a separate function would make things a lot clearer; allowing the reader to distinguish between a typical framework invocation and the case where jsdom has been used.

I’ve named this function browser — I think this makes the intent fairly clear. I wrote a short test to get a feel for a potential API:

turing.browser('<p><a class="the-link" href="http://jsdom.org">JSDOM\'s Homepage</a></p>', function(turing) {
  assert.equal(1, turing('p').length);
});

As this is a framework-provided method, I decided to wrap jsdom.env’s callback and pass in the turing function, rather than errors and window.

I found that done seemed to fire multiple times unless I read all the JavaScript first:

module.exports.browser = function(html, fn) {
  var js = '',
      files = ('turing.core.js turing.oo.js turing.enumerable.js turing.promise.js '
              + 'turing.functional.js turing.dom.js turing.plugins.js turing.events.js '
              + 'turing.net.js turing.touch.js turing.anim.js').split(' ');

  files.forEach(function(file) {
    js += fs.readFileSync(__dirname + '/../' + file);
  });

  // The JavaScript in as an array seems to make `done` fire twice
  jsdom.env({
    html: html,
    src: [ js ],
    done: function(err, window) {
      if (err) throw(err);
      fn(window.turing);
    }
  });
};

In the end I decided to make it read all of the files in the framework.

Events

When I first wrote the previous test I only loaded the DOM and core modules. Then I realised jsdom should also be able to cope with events. To test events work, I tried triggering a simple click handler:

turing.browser('<p><a class="the-link" href="http://jsdom.org">JSDOM\'s Homepage</a></p>', function(turing) {
  var triggered = 0;
  assert.equal(1, turing('p').length);
  turing('a').bind('click', function() {
    triggered++;
  });
  turing.events.fire(turing('a')[0], 'click');
  assert.equal(1, triggered);
});

This actually passes and demonstrates that jsdom.env is fine for both querying the DOM and simulating events.

Conclusion

If you’ve got a cool client-side script and want to distribute it with npm, wrapping certain functionality with jsdom might provide people with new ways to use your code. The most obvious example is screen scraping, but it would also be convenient for writing tests that run in the console, or maybe for indexing documents in a search engine.

I’ve committed these changes in 98ec3af and published the npm module.


blog comments powered by Disqus