Fork me on GitHub Tobi

Tobi

Expressive server-side functional testing with jQuery and jsdom.

should

lib/assertions/should.js

Module dependencies.

var Assertion = require('should').Assertion
  , statusCodes = require('http').STATUS_CODES
  , j = function(elem){ return '[jQuery ' + i(elem.selector.replace(/^ *\* */, '')) + ']'; }
  , i = require('sys').inspect;

Number strings.

var nums = { 0: 'none', 1: 'one', 2: 'two', 3: 'three' };

Return string representation for n.

  • param: Number n

  • return: String

  • api: private

function n(n) { return nums[n] || n; }

Assert text as str or a RegExp.

  • param: String | RegExp str

  • return: Assertion for chaining

  • api: public

Assertion.prototype.text = function(str){
  var elem = this.obj
    , text = elem.text();

  if (str instanceof RegExp) {
    this.assert(
        str.test(text)
      , 'expected ' + j(elem)+ ' to have text matching ' + i(str)
      , 'expected ' + j(elem) + ' text ' + i(text) + ' to not match ' + i(str));
  } else {
    this.assert(
        str == text
      , 'expected ' + j(elem) + ' to have text ' + i(str) + ', but has ' + i(text)
      , 'expected ' + j(elem) + ' to not have text ' + i(str));
  }

  return this;
};

Assert that many child elements are present via selector. When negated, <= 1 is a valid length.

  • param: String selector

  • return: Assertion for chaining

  • api: public

Assertion.prototype.many = function(selector){
  var elem = this.obj
    , elems = elem.find(selector)
    , len = elems.length;

  this.assert(
      this.negate ? len &gt; 1 : len
    , 'expected ' + j(elem) + ' to have many ' + i(selector) + ' tags, but has ' + n(len)
    , 'expected ' + j(elem) + ' to not have many ' + i(selector) + ' tags, but has ' + n(len));

  return this;
};

Assert that one child element is present via selector with optional text assertion..

  • param: String selector

  • param: String text

  • return: Assertion for chaining

  • api: public

Assertion.prototype.one = function(selector, text){
  var elem = this.obj
    , elems = elem.find(selector)
    , len = elems.length;

  this.assert(
      1 == len
    , 'expected ' + j(elem) + ' to have one ' + i(selector) + ' tag, but has ' + n(len)
    , 'expected ' + j(elem) + ' to not have one ' + i(selector) + ' tag, but has ' + n(len));

  if (undefined != text) {
    elems.should.have.text(text);
  }

  return this;
};

Assert existance attr key with optional val.

  • param: String key

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.attr = function(key, val){
  var elem = this.obj
    , attr = elem.attr(key);

  if (!val || (val &amp;&amp; !this.negate)) {
    this.assert(
        attr.length
      , 'expected ' + j(elem) + ' to have attribute ' + i(key)
      , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ', but has ' + i(attr));
  }

  if (val) {
    this.assert(
        val == attr
      , 'expected ' + j(elem) + ' to have attribute ' + i(key) + ' with ' + i(val) + ', but has ' + i(attr)
      , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ' with ' + i(val));
  }

  return this;
};

Assert presence of the given class name.

  • param: String name

  • return: Assertion for chaining

  • api: public

Assertion.prototype.class = function(name){
  var elem = this.obj;

  this.assert(
      elem.hasClass(name)
    , 'expected ' + j(elem) + ' to have class ' + i(name) + ', but has ' + i(elem.attr('class'))
    , 'expected ' + j(elem) + ' to not have class ' + i(name));

  return this;
};

Assert that header field has the given val.

  • param: String field

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.header = function(field, val){
  this.obj.should.have.property('headers');
  this.obj.headers.should.have.property(field.toLowerCase(), val);
  return this;
};

Assert .statusCode of code.

  • param: Number code

  • return: Assertion for chaining

  • api: public

Assertion.prototype.status = function(code){
  this.obj.should.have.property('statusCode');
  var status = this.obj.statusCode;

  this.assert(
      code == status
    , 'expected response code of ' + code + ' ' + i(statusCodes[code])
      + ', but got ' + status + ' ' + i(statusCodes[status])
    , 'expected to not respond with ' + code + ' ' + i(statusCodes[code]));

  return this;
};

Assert id attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.id = attr('id');

Assert title attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.title = attr('title');

Assert alt attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.alt = attr('alt');

Assert href attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.href = attr('href');

Assert src attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.src = attr('src');

Assert rel attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.rel = attr('rel');

Assert media attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.media = attr('media');

Assert name attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.name = attr('name');

Assert action attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.action = attr('action');

Assert method attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.method = attr('method');

Assert value attribute.

  • param: String val

  • return: Assertion for chaining

  • api: public

Assertion.prototype.value = attr('value');

Assert enabled.

  • return: Assertion for chaining

  • api: public

Assertion.prototype.__defineGetter__('enabled', function(){
  var elem = this.obj
    , disabled = elem.attr('disabled');

  this.assert(
      !disabled
    , 'expected ' + j(elem) + ' to be enabled'
    , '<not implemented, use .disabled>');

  return this;
});

Assert disabled.

  • return: Assertion for chaining

  • api: public

Assertion.prototype.__defineGetter__('disabled', function(){
  var elem = this.obj
    , disabled = elem.attr('disabled');

  this.assert(
      disabled
    , 'expected ' + j(elem) + ' to be disabled'
    , '<not implemented, use .enabled>');

  return this;
});

Assert checked.

  • return: Assertion for chaining

  • api: public

Assertion.prototype.__defineGetter__('checked', bool('checked'));

Assert selected.

  • return: Assertion for chaining

  • api: public

Assertion.prototype.__defineGetter__('selected', bool('selected'));

Generate a boolean assertion function for the given attr name.

  • param: String name

  • return: Function

  • api: private

function bool(name) {
  return function(){
    var elem = this.obj;

    this.assert(
        elem.attr(name)
      , 'expected ' + j(elem) + ' to be ' + name
      , 'expected ' + j(elem) + ' to not be ' + name);

    return this;
  }
}

Generate an attr assertion function for the given attr name.

  • param: String name

  • return: Function

  • api: private

function attr(name) {
  return function(expected){
    var elem = this.obj
      , val = elem.attr(name);

    this.assert(
        expected == val
      , 'expected ' + j(elem) + ' to have ' + name + ' ' + i(expected) + ', but has ' + i(val)
      , 'expected ' + j(elem) + ' to not have ' + name + ' ' + i(expected));

    return this;
  }
}

browser

lib/browser.js

Module dependencies.

var EventEmitter = require('events').EventEmitter
  , Cookie = require('./cookie')
  , CookieJar = require('./cookie/jar')
  , jsdom = require('jsdom')
  , jQuery = require('../support/jquery')
  , http = require('http');

Starting portno.

var port = 8888;

Initialize a new Browser with the given html or server.

  • param: String | http.Server html

  • api: public

var Browser = module.exports = exports = function Browser(html) {
  this.history = [];
  this.cookieJar = new CookieJar;
  if ('string' == typeof html) {
    this.parse(html);
  } else {
    this.server = html;
    this.server.pending = 0;
    this.server.port = 8888;
  }
};

Inherit from EventEmitter.prototype.

Browser.prototype.__proto__ = EventEmitter.prototype;

Parse the given html and populate:

  • .source
  • .window
  • .jQuery

  • param: String html

  • api: public

Browser.prototype.parse = function(html){
  this.source = html;
  this.window = jsdom.jsdom(wrap(html)).createWindow();
  this.jQuery = jQuery.create(this.window);
  this.jQuery.browser = this.jQuery.fn.browser = this;
  require('./jquery')(this, this.jQuery);
  this.context = this.jQuery('*');
};

Set the jQuery context for the duration of fn() to selector.

  • param: String selector

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.within =
Browser.prototype.context = function(selector, fn){
  var prev = this.context;
  this.context = this.context.find(selector);
  fn();
  this.context = prev;
  return this;
};

Request path with method and callback fn(jQuery).

  • param: String path

  • param: String method

  • param: Object options

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.request = function(method, path, options, fn, saveHistory){
  var self = this
    , server = this.server
    , host = '127.0.0.1'
    , headers = options.headers || {};

  // Ensure server
  if (!server) throw new Error('no .server present');
  ++server.pending;

  // HTTP client
  // TODO: options for headers, request body etc
  if (!server.fd) {
    server.listen(++port, host);
    server.client = http.createClient(port);
  }

  // Save history
  if (false !== saveHistory) this.history.push(path);

  // Cookies
  var cookies = this.cookieJar.get({ url: path });
  if (cookies.length) {
    headers.Cookie = cookies.map(function(cookie){
      return cookie.name + '=' + cookie.value;
    }).join('; ');
  }

  // Request
  headers.Host = host;
  var req = server.client.request(method, path, headers);
  req.on('response', function(res){
    var status = res.statusCode
      , buf = '';

    // Cookies
    if (res.headers['set-cookie']) {
      self.cookieJar.add(new Cookie(res.headers['set-cookie']));
    }

    // Success
    if (status &gt;= 200 &amp;&amp; status &lt; 300) {
      var contentType = res.headers['content-type'];

      // JSON support
      if (~contentType.indexOf('json')) {
        res.body = '';
        res.on('data', function(chunk){ res.body += chunk; });
        res.on('end', function(){
          try {
            res.body = JSON.parse(res.body);
            fn(res);
          } catch (err) {
            self.emit('error', err);
          }
        });
        return;
      }

      // Ensure html
      if (!~contentType.indexOf('text/html')) {
        return fn(res);
      }

      // Buffer html
      res.setEncoding('utf8');
      res.on('data', function(chunk){ buf += chunk; });
      res.on('end', function(){
        self.parse(buf);
        fn(res, function(selector){
          return self.context.find(selector);
        });
      });

    // Redirect
    } else if (status &gt;= 300 &amp;&amp; status &lt; 400) {
      var location = res.headers.location;
      self.emit('redirect', location);
      self.request('GET', location, options, fn);

    // Error
    } else {
      var err = new Error(
          method + ' ' + path
        + ' responded with '
        + status + ' "' + http.STATUS_CODES[status] + '"');
      self.emit('error', err);
    }
  });

  req.end(options.body);

  return this;
};

GET path and callback fn(jQuery).

  • param: String path

  • param: Object | Function options or fn

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.get = 
Browser.prototype.visit =
Browser.prototype.open = function(path, options, fn, saveHistory){
  if ('function' == typeof options) {
    saveHistory = fn;
    fn = options;
    options = {};
  }
  return this.request('GET', path, options, fn, saveHistory);
};

POST path and callback fn(jQuery).

  • param: String path

  • param: Object | Function options or fn

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.post = function(path, options, fn, saveHistory){
  if ('function' == typeof options) {
    saveHistory = fn;
    fn = options;
    options = {};
  }
  return this.request('POST', path, options, fn, saveHistory);
};

GET the last page visited, or the nth previous page.

  • param: Number n

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.back = function(n, fn){
  if ('function' == typeof n) fn = n, n = 1;
  while (n--) this.history.pop();
  return this.get(this.path, fn, false);
};

Locate elements via the given selector and locator supporting:

  • element text
  • element name attribute
  • css selector

  • param: String selector

  • param: String locator

  • return: jQuery

  • api: public

Browser.prototype.locate = function(selector, locator){
  var self = this
    , $ = this.jQuery;
  var elems = this.context.find(selector).filter(function(){
    var elem = $(this);
    return locator == elem.text()
      || locator == elem.attr('id')
      || locator == elem.attr('value')
      || locator == elem.attr('name')
      || elem.is(locator);
  });
  if (elems &amp;&amp; !elems.length) throw new Error('failed to locate "' + locator + '" in context of selector "' + selector + '"');
  return elems;
};

Return the current path.

  • return: String

  • api: public

Browser.prototype.__defineGetter__('path', function(){
  return this.history[this.history.length - 1];
});

Click the given locator and callback fn(res).

  • param: String locator

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.click = function(locator, fn){
  return this.jQuery(this.locate(':submit, :button, a', locator)).click(fn, locator);
};

Assign val to the given locator.

  • param: String locator

  • param: String val

  • return: Browser for chaining

  • api: public

Browser.prototype.type = function(locator, val){
  this.jQuery(this.locate('input, textarea', locator)).val(val);
  return this;
};

Uncheck the checkbox with the given locator.

  • param: String locator

  • return: Assertion for chaining

  • api: public

Browser.prototype.uncheck = function(locator){
  this.locate(':checkbox', locator)[0].removeAttribute('checked');
  return this;
};

Check the checkbox with the given locator.

  • param: String locator

  • return: Assertion for chaining

  • api: public

Browser.prototype.check = function(locator){
  this.locate(':checkbox', locator)[0].setAttribute('checked', 'checked');
  return this;
};

Select options at locator.

  • param: String locator

  • param: String | Array select

  • return: Assertion for chaining

  • api: public

Browser.prototype.select = function(locator, options){
  this.jQuery(this.locate('select', locator)).select(options);
  return this;
};

Submit form at the optional locator and callback fn(res).

  • param: String | Function locator

  • param: Function fn

  • return: Browser for chaining

  • api: public

Browser.prototype.submit = function(locator, fn){
  if ('function' == typeof locator) {
    fn = locator;
    locator = '*';
  }
  return this.jQuery(this.locate('form', locator)).submit(fn, locator);
};

Fill the given form fields and optional locator.

  • param: String locator

  • param: Object fields

  • return: Assertion for chaining

  • api: public

Browser.prototype.fill = function(locator, fields){
  if ('object' == typeof locator) {
    fields = locator;
    locator = '*';
  }
  this.jQuery(this.locate('form', locator)).fill(fields);
  return this;
};

Ensure html / body tags exist.

  • return: String

  • api: public

function wrap(html) {
  // body
  if (!~html.indexOf('<body')) {
    html = '<body>' + html + '</body>';
  }

  // html
  if (!~html.indexOf('<html')) {
    html = '<html>' + html + '</html>';
  }

  return html;
}

index

lib/cookie/index.js

Module dependencies.

var url = require('url');

Initialize a new Cookie with the given cookie str and req.

  • param: String str

  • param: IncomingRequest req

  • api: private

var Cookie = exports = module.exports = function Cookie(str, req) {
  this.str = str;

  // First key is the name
  this.name = str.substr(0, str.indexOf('='));

  // Map the key/val pairs
  str.split(/ *; */).reduce(function(obj, pair){
    pair = pair.split(/ *= */);
    obj[pair[0]] = pair[1] || true;
    return obj;
  }, this);

  // Assign value
  this.value = this[this.name];

  // Expires
  this.expires = this.expires
    ? new Date(this.expires)
    : Infinity;

  // Default or trim path
  this.path = this.path
    ? this.path.trim()
    : url.parse(req.url).pathname;
};

Return the original cookie string.

  • return: String

  • api: public

Cookie.prototype.toString = function(){
  return this.str;
};

jar

lib/cookie/jar.js

Module dependencies.

var url = require('url');

Initialize a new CookieJar.

  • api: private

var CookieJar = exports = module.exports = function CookieJar() {
  this.cookies = [];
};

Add the given cookie to the jar.

  • param: Cookie cookie

  • api: public

CookieJar.prototype.add = function(cookie){
  this.cookies.push(cookie);
};

CookieJar.prototype.get = function(req){
  var path = url.parse(req.url).pathname
    , now = new Date;
  return this.cookies.filter(function(cookie){
    return 0 == path.indexOf(cookie.path)
      &amp;&amp; now &lt; cookie.expires;
  });
};

fill

lib/jquery/fill.js

Select options.

exports.select = function($, elems, val){
  elems.select(val);
};

Fill inputs:

  • toggle radio buttons
  • check checkboxes
  • default to .val(val)

exports.input = function($, elems, val){
  switch (elems.attr('type')) {
    case 'radio':
      elems.each(function(){
        var elem = $(this);
        val == elem.attr('value')
          ? elem.attr('checked', true)
          : elem.removeAttr('checked');
      });
      break;
    case 'checkbox':
      val
        ? elems.attr('checked', true)
        : elems.removeAttr('checked');
      break;
    default:
      elems.val(val);
  }
};

Fill textarea.

exports.textarea = function($, elems, val){
  elems.val(val);
}

index

lib/jquery/index.js

Module dependencies.

var parse = require('url').parse
  , fill = require('./fill');

Augment the given jQuery instance.

  • param: Browser browser

  • param: jQuery $

  • api: private

module.exports = function(browser, $){

Select the given options by text or value attr.

  • param: Array options

  • api: public

$.fn.select = function(options){
    if ('string' == typeof options) options = [options];

    this.find('option').filter(function(i, option){
      // via text or value
      var selected = ~options.indexOf($(option).text())
        || ~options.indexOf(option.getAttribute('value'));

      if (selected) {
        option.setAttribute('selected', 'selected');
      } else {
        option.removeAttribute('selected');
      }
    })

    return this;
  };

Click the first element with callback fn(jQuery, res) when text/html or fn(res) otherwise.

  • requests a tag href
  • requests form submit's parent form action

  • param: Function fn

  • api: public

$.fn.click = function(fn, locator){
    var url
      , prop = 'element'
      , method = 'get'
      , locator = locator || this.selector
      , options = {};

    switch (this[0].nodeName) {
      case 'A':
        prop = 'href';
        url = this.attr('href');
        break;
      case 'INPUT':
        if ('submit' == this.attr('type')) {
          var form = this.parent('form').first();
          method = form.attr('method') || 'get';
          url = form.attr('action') || parse($.browser.path).pathname;
          if ('get' == method) {
            url += '?' + form.serialize();
          } else  {
            options.body = form.serialize();
            options.headers = {
              'Content-Type': 'application/x-www-form-urlencoded'
            };
          }
        }
        break;
    }

    // Ensure url present
    if (!url) throw new Error('failed to click ' + locator + ', ' + prop + ' not present');

    // Perform request
    browser[method](url, options, fn);
    
    return this;
  };

Apply fill rules to the given fields.

  • param: Object fields

  • api: public

$.fn.fill = function(fields){
    for (var locator in fields) {
      var val = fields[locator]
        , elems = browser.locate('select, input, textarea', locator)
        , name = elems[0].nodeName.toLowerCase();
      fill[name]($, elems, val);
    }
    return this;
  };

Submit this form with the given callback fn.

  • param: Function fn

  • api: public

$.fn.submit = function(fn, locator){
    var submit = this.find(':submit');
    if (submit.length) {
      submit.click(fn, locator);
    } else {
      $('<input id="tobi-submit" type="submit" />')
        .appendTo(this)
        .click(fn, locator)
        .remove();
    }
  };
};

tobi

lib/tobi.js

Library version.

exports.version = '0.0.1';

Expose Browser.

exports.Browser = require('./browser');

Expose Cookie.

exports.Cookie = require('./cookie');

Expose CookieJar.

exports.CookieJar = require('./cookie/jar');

Initialize a new Browser.

exports.createBrowser = function(str){
  return new exports.Browser(str);
};

Automated should.js support.

try {
  require('should');
  require('./assertions/should');
} catch (err) {
  // Ignore
}