├── .gitmodules ├── .gitignore ├── index.js ├── .npmignore ├── test ├── tobi.test.js ├── browser.test.js ├── cookie.test.js ├── browser.external.test.js ├── jquery.test.js ├── browser.context.test.js ├── scenarios.login.test.js ├── cookie.jar.test.js ├── assertions.test.js └── browser.navigation.test.js ├── examples ├── google.js ├── multiple.js ├── login.js ├── wizard.js └── app.js ├── Makefile ├── lib ├── tobi.js ├── jquery │ ├── fill.js │ └── index.js ├── cookie │ ├── index.js │ └── jar.js ├── assertions │ └── should.js └── browser.js ├── package.json ├── History.md ├── Readme.md └── index.html /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | node_modules 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('./lib/tobi'); -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | support 3 | examples 4 | node_modules 5 | -------------------------------------------------------------------------------- /test/tobi.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , should = require('should'); 8 | 9 | module.exports = { 10 | 'test .version': function(){ 11 | tobi.version.should.match(/^\d+\.\d+\.\d+$/); 12 | } 13 | }; -------------------------------------------------------------------------------- /examples/google.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | var tobi = require('../') 6 | , should = require('should') 7 | , browser = tobi.createBrowser(80, 'www.google.com'); 8 | 9 | browser.get('/', function(res){ 10 | res.should.have.status(200); 11 | browser.click("input[name=btnG]", function(res, $){ 12 | $('title').should.have.text('Google'); 13 | }); 14 | }); -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | SRC = $(shell find lib -type f) 3 | REPORTER = list 4 | 5 | test: 6 | @./node_modules/.bin/mocha \ 7 | --timeout 4s \ 8 | --slow 1000 \ 9 | --growl \ 10 | --reporter $(REPORTER) \ 11 | --ui exports 12 | 13 | docs: index.html 14 | 15 | index.html: $(SRC) 16 | dox \ 17 | --title "Tobi" \ 18 | --desc "Expressive server-side functional testing with jQuery and jsdom." \ 19 | --ribbon "http://github.com/learnboost/tobi" \ 20 | --private \ 21 | $^ > $@ 22 | 23 | .PHONY: test docs -------------------------------------------------------------------------------- /lib/tobi.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Library version. 10 | */ 11 | 12 | exports.version = '0.3.2'; 13 | 14 | /** 15 | * Expose `Browser`. 16 | */ 17 | 18 | exports.Browser = require('./browser'); 19 | 20 | /** 21 | * Expose `Cookie`. 22 | */ 23 | 24 | exports.Cookie = require('./cookie'); 25 | 26 | /** 27 | * Expose `CookieJar`. 28 | */ 29 | 30 | exports.CookieJar = require('./cookie/jar'); 31 | 32 | /** 33 | * Initialize a new `Browser`. 34 | */ 35 | 36 | exports.createBrowser = function(a,b,c){ 37 | return new exports.Browser(a,b,c); 38 | }; 39 | 40 | /** 41 | * Automated should.js support. 42 | */ 43 | 44 | try { 45 | require('should'); 46 | require('./assertions/should'); 47 | } catch (err) { 48 | // Ignore 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { "name": "tobi" 2 | , "description": "expressive server-side functional testing with jQuery and jsdom" 3 | , "version": "0.3.2" 4 | , "author": "TJ Holowaychuk " 5 | , "keywords": ["test", "testing", "browser", "jquery", "css"] 6 | , "dependencies": { 7 | "jsdom": ">= 0.1.21" 8 | , "htmlparser": ">= 1.7.3" 9 | , "should": ">= 0.0.4" 10 | , "qs": ">= 0.1.0" 11 | } 12 | , "devDependencies": { 13 | "express": "2.3.x" 14 | , "mocha": "*" 15 | , "htmlparser": ">= 0.0.1" 16 | , "jsdom": ">= 0.0.1" 17 | , "qs": ">= 0.0.1" 18 | , "should": ">= 0.0.1" 19 | , "connect": ">= 0.0.1" 20 | } 21 | , "main": "./index.js" 22 | , "engines": { "node": ">= 0.4.x < 0.7.0" } 23 | , "repository": { 24 | "type": "git" 25 | , "url": "git://github.com/LearnBoost/tobi.git" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/browser.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , Browser = tobi.Browser 9 | , should = require('should'); 10 | 11 | var app = express.createServer(); 12 | 13 | module.exports = { 14 | 'test Browser(str)': function(){ 15 | var html = '' 16 | , browser = new Browser(html); 17 | browser.should.have.property('source', html); 18 | browser.should.have.property('window'); 19 | browser.should.have.property('jQuery'); 20 | }, 21 | 22 | 'test .createBrowser(str)': function(){ 23 | var html = '' 24 | , browser = tobi.createBrowser(html); 25 | browser.should.have.property('source', html); 26 | }, 27 | 28 | 'test .createBrowser(server)': function(){ 29 | var browser = tobi.createBrowser(app); 30 | browser.should.have.property('server', app); 31 | } 32 | }; -------------------------------------------------------------------------------- /examples/multiple.js: -------------------------------------------------------------------------------- 1 | 2 | // Expose support modules 3 | 4 | require.paths.unshift(__dirname + '/../support'); 5 | 6 | /** 7 | * Module dependencies. 8 | */ 9 | 10 | var app = require('./app') 11 | , tobi = require('../') 12 | , should = require('should') 13 | , pending = 2; 14 | 15 | var a = tobi.createBrowser(app); 16 | 17 | a.get('/wizard', function(res, $){ 18 | res.should.have.status(200); 19 | $('h1').should.have.text('Account'); 20 | console.log('a successful'); 21 | --pending || app.close(); 22 | }); 23 | 24 | var b = tobi.createBrowser(app); 25 | 26 | b.get('/login', function(res, $){ 27 | $('form') 28 | .fill({ username: 'tj', password: 'tobi' }) 29 | .submit(function(res, $){ 30 | res.should.have.status(200); 31 | res.should.have.header('Content-Length'); 32 | res.should.have.header('Content-Type', 'text/html; charset=utf-8'); 33 | console.log('b successful'); 34 | --pending || app.close(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /lib/jquery/fill.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi - jQuery - fill 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Select options. 10 | */ 11 | 12 | exports.select = function($, elems, val){ 13 | elems.select(val); 14 | }; 15 | 16 | /** 17 | * Fill inputs: 18 | * 19 | * - toggle radio buttons 20 | * - check checkboxes 21 | * - default to .val(val) 22 | * 23 | */ 24 | 25 | exports.input = function($, elems, val){ 26 | switch (elems.attr('type')) { 27 | case 'radio': 28 | elems.each(function(){ 29 | var elem = $(this); 30 | val == elem.attr('value') 31 | ? elem.attr('checked', true) 32 | : elem.removeAttr('checked'); 33 | }); 34 | break; 35 | case 'checkbox': 36 | val 37 | ? elems.attr('checked', true) 38 | : elems.removeAttr('checked'); 39 | break; 40 | default: 41 | elems.val(val); 42 | } 43 | }; 44 | 45 | /** 46 | * Fill textarea. 47 | */ 48 | 49 | exports.textarea = function($, elems, val){ 50 | elems.val(val); 51 | } -------------------------------------------------------------------------------- /test/cookie.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , Cookie = tobi.Cookie 8 | , should = require('should'); 9 | 10 | var str = 'connect.sid=s543qactge.wKE61E01Bs%2BKhzmxrwrnug; path=/; httpOnly; expires=Sat, 04 Dec 2010 23:27:28 GMT'; 11 | var cookie = new Cookie(str); 12 | 13 | module.exports = { 14 | 'test .toString()': function(){ 15 | cookie.toString().should.equal(str); 16 | }, 17 | 18 | 'test .path': function(){ 19 | cookie.should.have.property('path', '/'); 20 | }, 21 | 22 | 'test .path default': function(){ 23 | var cookie = new Cookie('foo=bar', { url: 'http://foo.com/bar' }); 24 | cookie.should.have.property('path', '/bar'); 25 | }, 26 | 27 | 'test .httpOnly': function(){ 28 | cookie.should.have.property('httpOnly', true); 29 | }, 30 | 31 | 'test .name': function(){ 32 | cookie.should.have.property('name', 'connect.sid'); 33 | }, 34 | 35 | 'test .value': function(){ 36 | cookie.should.have.property('value', 's543qactge.wKE61E01Bs%2BKhzmxrwrnug'); 37 | }, 38 | 39 | 'test .expires': function(){ 40 | cookie.should.have.property('expires'); 41 | cookie.expires.should.be.an.instanceof(Date); 42 | cookie.expires.getDay().should.equal(6); 43 | } 44 | }; -------------------------------------------------------------------------------- /lib/cookie/index.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi - Cookie 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Module dependencies. 10 | */ 11 | 12 | var url = require('url'); 13 | 14 | /** 15 | * Initialize a new `Cookie` with the given cookie `str` and `req`. 16 | * 17 | * @param {String} str 18 | * @param {IncomingRequest} req 19 | * @api private 20 | */ 21 | 22 | var Cookie = exports = module.exports = function Cookie(str, req) { 23 | this.str = str; 24 | 25 | // First key is the name 26 | this.name = str.substr(0, str.indexOf('=')); 27 | 28 | // Map the key/val pairs 29 | str.split(/ *; */).reduce(function(obj, pair){ 30 | pair = pair.split(/ *= */); 31 | obj[pair[0]] = pair[1] || true; 32 | return obj; 33 | }, this); 34 | 35 | // Assign value 36 | this.value = this[this.name]; 37 | 38 | // Expires 39 | this.expires = this.expires 40 | ? new Date(this.expires) 41 | : Infinity; 42 | 43 | // Default or trim path 44 | this.path = this.path 45 | ? this.path.trim() 46 | : url.parse(req.url).pathname; 47 | }; 48 | 49 | /** 50 | * Return the original cookie string. 51 | * 52 | * @return {String} 53 | * @api public 54 | */ 55 | 56 | Cookie.prototype.toString = function(){ 57 | return this.str; 58 | }; 59 | -------------------------------------------------------------------------------- /examples/login.js: -------------------------------------------------------------------------------- 1 | 2 | // Expose support modules 3 | 4 | require.paths.unshift(__dirname + '/../support'); 5 | 6 | /** 7 | * Module dependencies. 8 | */ 9 | 10 | var app = require('./app') 11 | , tobi = require('../') 12 | , should = require('should') 13 | , browser = tobi.createBrowser(app); 14 | 15 | browser.get('/login', function(res, $){ 16 | $('form') 17 | .should.have.action('/login') 18 | .and.have.id('user') 19 | .and.have.method('post') 20 | .and.have.many('input'); 21 | 22 | $('form > input[name=username]').should.have.attr('type', 'text'); 23 | $('form > input[name=password]').should.have.attr('type', 'password'); 24 | $('form :submit').should.have.value('Login'); 25 | }); 26 | 27 | browser.get('/login', function(res, $){ 28 | $('form') 29 | .fill({ username: 'tj', password: 'tobi' }) 30 | .submit(function(res, $){ 31 | res.should.have.status(200); 32 | res.should.have.header('Content-Length'); 33 | res.should.have.header('Content-Type', 'text/html; charset=utf-8'); 34 | $('ul.messages').should.have.one('li', 'Successfully authenticated'); 35 | browser.get('/login', function(res, $){ 36 | res.should.have.status(200); 37 | $('ul.messages').should.have.one('li', 'Already authenticated'); 38 | console.log('successful'); 39 | app.close(); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /examples/wizard.js: -------------------------------------------------------------------------------- 1 | 2 | // Expose support modules 3 | 4 | require.paths.unshift(__dirname + '/../support'); 5 | 6 | /** 7 | * Module dependencies. 8 | */ 9 | 10 | var app = require('./app') 11 | , tobi = require('../') 12 | , should = require('should') 13 | , browser = tobi.createBrowser(app); 14 | 15 | browser.get('/wizard', function(res, $){ 16 | res.should.have.status(200); 17 | $('h1').should.have.text('Account'); 18 | $('form') 19 | .fill({ 20 | name: 'tj' 21 | , email: 'tj@learnboost.com' 22 | }).submit(function(res, $){ 23 | res.should.have.status(200); 24 | $('h1').should.have.text('Details'); 25 | $('form') 26 | .fill({ city: 'Victoria' }) 27 | .find('[value=Continue]') 28 | .click(function(res, $){ 29 | res.should.have.status(200); 30 | $('h1').should.have.text('Review'); 31 | $(':submit').should.have.value('Complete'); 32 | $('ul li:nth-child(1)').should.have.text('Name: tj'); 33 | $('ul li:nth-child(2)').should.have.text('Email: tj@learnboost.com'); 34 | $('ul li:nth-child(3)').should.have.text('City: victoria'); 35 | $('form').submit(function(res, $){ 36 | res.should.have.status(200); 37 | $('h1').should.have.text('Registration Complete'); 38 | $('ul li:nth-child(1)').should.have.text('Name: tj'); 39 | $('ul li:nth-child(2)').should.have.text('Email: tj@learnboost.com'); 40 | $('ul li:nth-child(3)').should.have.text('City: victoria'); 41 | console.log('successful'); 42 | app.close(); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/cookie/jar.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi - CookieJar 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Module dependencies. 10 | */ 11 | 12 | var url = require('url'); 13 | 14 | /** 15 | * Initialize a new `CookieJar`. 16 | * 17 | * @api private 18 | */ 19 | 20 | var CookieJar = exports = module.exports = function CookieJar() { 21 | this.cookies = []; 22 | }; 23 | 24 | /** 25 | * Add the given `cookie` to the jar. 26 | * 27 | * @param {Cookie} cookie 28 | * @api private 29 | */ 30 | 31 | CookieJar.prototype.add = function(cookie){ 32 | this.cookies = this.cookies.filter(function(c){ 33 | // Avoid duplication (same path, same name) 34 | return !(c.name == cookie.name && c.path == cookie.path); 35 | }); 36 | this.cookies.push(cookie); 37 | }; 38 | 39 | /** 40 | * Get cookies for the given `req`. 41 | * 42 | * @param {IncomingRequest} req 43 | * @return {Array} 44 | * @api private 45 | */ 46 | 47 | CookieJar.prototype.get = function(req){ 48 | var path = url.parse(req.url).pathname 49 | , now = new Date 50 | , specificity = {}; 51 | return this.cookies.filter(function(cookie){ 52 | if (0 == path.indexOf(cookie.path) && now < cookie.expires 53 | && cookie.path.length > (specificity[cookie.name] || 0)) 54 | return specificity[cookie.name] = cookie.path.length; 55 | }); 56 | }; 57 | 58 | /** 59 | * Return Cookie string for the given `req`. 60 | * 61 | * @param {IncomingRequest} req 62 | * @return {String} 63 | * @api private 64 | */ 65 | 66 | CookieJar.prototype.cookieString = function(req){ 67 | var cookies = this.get(req); 68 | if (cookies.length) { 69 | return cookies.map(function(cookie){ 70 | return cookie.name + '=' + cookie.value; 71 | }).join('; '); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /test/browser.external.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , Browser = tobi.Browser 9 | , should = require('should'); 10 | 11 | // Test app 12 | 13 | var app = express.createServer(); 14 | 15 | app.use(express.bodyParser()); 16 | 17 | app.get('/remote/script.js', function(req, res){ 18 | res.header('Content-Type', 'application/javascript'); 19 | res.send('document.getElementById("para").innerHTML = "new";'); 20 | }); 21 | 22 | app.get('/remote', function(req, res){ 23 | res.send('

old

') 24 | }); 25 | 26 | app.get('/script', function(req, res){ 27 | res.send('

old

'); 28 | }); 29 | 30 | module.exports = { 31 | 'test external option disabled': function(done){ 32 | var browser = tobi.createBrowser(app); 33 | browser.get('/script', function(res, $){ 34 | res.should.have.status(200); 35 | $('p').should.have.text('old'); 36 | done(); 37 | }); 38 | }, 39 | 40 | 'test external option enabled': function(done){ 41 | var browser = tobi.createBrowser(app, { external: true }); 42 | browser.get('/script', function(res, $){ 43 | res.should.have.status(200); 44 | $('p').should.have.text('new'); 45 | done(); 46 | }); 47 | }, 48 | 49 | // 'test remote scripts': function(done){ 50 | // var browser = tobi.createBrowser(app, { external: true }); 51 | // browser.get('/remote', function(res, $){ 52 | // res.should.have.status(200); 53 | // $('p').should.have.text('new'); 54 | // done(); 55 | // }); 56 | // }, 57 | 58 | after: function(){ 59 | app.close(); 60 | } 61 | }; -------------------------------------------------------------------------------- /test/jquery.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , should = require('should'); 8 | 9 | function jquery(html) { 10 | return tobi.createBrowser(html).jQuery; 11 | } 12 | 13 | module.exports = { 14 | 'test .fill()': function(){ 15 | var $ = jquery('
' 16 | + '' 17 | + '' 18 | + '' 19 | + '' 20 | + '' 21 | + '' 22 | + '' 26 | + '
'); 27 | 28 | $('form').fill({ 29 | 'field-username': 'tjholowaychuk' 30 | , signature: 'display' 31 | , agreement: true 32 | , email: false 33 | , province: 'bc' 34 | }); 35 | 36 | $('form > input[name=username]').should.have.attr('value', 'tjholowaychuk'); 37 | $('form > input[value=display]').should.be.checked; 38 | $('form > input[name=agreement]').should.be.checked; 39 | $('form > input[name=email]').should.not.be.checked; 40 | $('select > option[value=bc]').should.be.selected; 41 | $('select > option[value=ab]').should.not.be.selected; 42 | 43 | $('form').fill({ province: ['ab', 'bc'] }); 44 | 45 | $('select > option[value=bc]').should.be.selected; 46 | $('select > option[value=ab]').should.be.selected; 47 | 48 | $('form').fill({ province: 'ab' }); 49 | 50 | $('select > option[value=bc]').should.not.be.selected; 51 | $('select > option[value=ab]').should.be.selected; 52 | } 53 | }; -------------------------------------------------------------------------------- /test/browser.context.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , Browser = tobi.Browser 9 | , should = require('should'); 10 | 11 | // Test app 12 | 13 | var app = express.createServer(); 14 | 15 | app.use(express.bodyParser()); 16 | 17 | app.get('/search', function(req, res){ 18 | res.send( 19 | '
' 22 | + '
' 23 | + ' ' 24 | + '
'); 25 | }); 26 | 27 | app.post('/search/users', function(req, res){ 28 | res.send({ users: true, headers: req.headers, body: req.body }); 29 | }); 30 | 31 | app.post('/search/posts', function(req, res){ 32 | res.send({ posts: true, headers: req.headers, body: req.body }); 33 | }); 34 | 35 | module.exports = { 36 | 'test global context': function(done){ 37 | var browser = tobi.createBrowser(app); 38 | browser.get('/search', function(res, $){ 39 | $('form').should.have.length(2); 40 | browser 41 | .type('query', 'foo bar') 42 | .submit(function(res){ 43 | res.body.should.have.property('users', true); 44 | res.body.body.should.eql({ query: 'foo bar' }); 45 | done(); 46 | }); 47 | }); 48 | }, 49 | 50 | 'test custom context': function(done){ 51 | var browser = tobi.createBrowser(app); 52 | browser.get('/search', function(res, $){ 53 | $('form').should.have.length(2); 54 | browser.within('div:nth-child(2)', function(){ 55 | $('> form').should.have.length(1); 56 | $('> input').should.have.length(0); 57 | 58 | browser.within('form', function(){ 59 | $('> form').should.have.length(0); 60 | $('> input').should.have.length(1); 61 | }); 62 | 63 | $('> form').should.have.length(1); 64 | $('> input').should.have.length(0); 65 | 66 | browser 67 | .type('query', 'foo bar') 68 | .submit(function(res){ 69 | res.body.should.have.property('posts', true); 70 | res.body.body.should.eql({ query: 'foo bar' }); 71 | done(); 72 | }); 73 | }); 74 | $('form').should.have.length(2); 75 | }); 76 | }, 77 | 78 | after: function(){ 79 | app.close(); 80 | } 81 | }; -------------------------------------------------------------------------------- /test/scenarios.login.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , connect = require('connect') 9 | , should = require('should'); 10 | 11 | // Test app 12 | 13 | var app = express.createServer(); 14 | 15 | app.use(express.bodyParser()); 16 | app.use(express.cookieParser()); 17 | app.use(express.session({ secret: 'something' })); 18 | 19 | app.get('/login', function(req, res){ 20 | var msgs = req.flash('info'); 21 | res.send( 22 | (msgs.length ? '
  • ' + msgs[0] + '
' : '') 23 | + '
' 24 | + ' ' 25 | + ' ' 26 | + ' ' 27 | + '
'); 28 | }); 29 | 30 | app.post('/login', function(req, res){ 31 | var username = req.body.username 32 | , password = req.body.password; 33 | 34 | // Fake authentication / validation 35 | if ('tj' == username && 'tobi' == password) { 36 | req.flash('info', 'Successfully authenticated'); 37 | } else if (!username) { 38 | req.flash('info', 'Username required'); 39 | } else if (!password) { 40 | req.flash('info', 'Password required'); 41 | } else { 42 | req.flash('info', 'Authentication failed'); 43 | } 44 | 45 | res.redirect('/login'); 46 | }); 47 | 48 | var browser = tobi.createBrowser(app); 49 | 50 | module.exports = { 51 | 'test /login with valid credentials': function(done){ 52 | browser.get('/login', function(res, $){ 53 | $('form') 54 | .fill({ username: 'tj', password: 'tobi' }) 55 | .submit(function(res, $){ 56 | res.should.have.status(200); 57 | $('ul.messages').should.have.one('li', 'Successfully authenticated'); 58 | done(); 59 | }); 60 | }); 61 | }, 62 | 63 | 'test /login with invalid credentials': function(done){ 64 | browser.get('/login', function(res, $){ 65 | $('form') 66 | .fill({ username: 'tj', password: 'foobar' }) 67 | .find(':submit') 68 | .click(function(res, $){ 69 | res.should.have.status(200); 70 | res.should.have.header('x-powered-by', 'Express'); 71 | res.should.have.header('X-Powered-By', 'Express'); 72 | $('ul.messages').should.have.one('li', 'Authentication failed'); 73 | done(); 74 | }); 75 | }); 76 | }, 77 | 78 | 'test /login with username omitted': function(done){ 79 | browser.get('/login', function(){ 80 | browser 81 | .type('password', 'not tobi') 82 | .submit('form', function(res, $){ 83 | res.should.have.status(200); 84 | $('ul.messages').should.have.one('li', 'Username required'); 85 | done(); 86 | }); 87 | }); 88 | }, 89 | 90 | after: function(){ 91 | app.close(); 92 | } 93 | }; -------------------------------------------------------------------------------- /test/cookie.jar.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , Cookie = tobi.Cookie 8 | , Jar = tobi.CookieJar 9 | , should = require('should'); 10 | 11 | function expires(ms) { 12 | return new Date(Date.now() + ms).toUTCString(); 13 | } 14 | 15 | module.exports = { 16 | 'test .get() expiration': function(done){ 17 | var jar = new Jar; 18 | var cookie = new Cookie('sid=1234; path=/; expires=' + expires(1000)); 19 | jar.add(cookie); 20 | setTimeout(function(){ 21 | var cookies = jar.get({ url: 'http://foo.com/foo' }); 22 | cookies.should.have.length(1); 23 | cookies[0].should.equal(cookie); 24 | setTimeout(function(){ 25 | var cookies = jar.get({ url: 'http://foo.com/foo' }); 26 | cookies.should.have.length(0); 27 | done(); 28 | }, 1000); 29 | }, 5); 30 | }, 31 | 32 | 'test .get() path support': function(){ 33 | var jar = new Jar; 34 | var a = new Cookie('sid=1234; path=/'); 35 | var b = new Cookie('sid=1111; path=/foo/bar'); 36 | var c = new Cookie('sid=2222; path=/'); 37 | jar.add(a); 38 | jar.add(b); 39 | jar.add(c); 40 | 41 | // should remove the duplicates 42 | jar.cookies.should.have.length(2); 43 | 44 | // same name, same path, latter prevails 45 | var cookies = jar.get({ url: 'http://foo.com/' }); 46 | cookies.should.have.length(1); 47 | cookies[0].should.equal(c); 48 | 49 | // same name, diff path, path specifity prevails, latter prevails 50 | var cookies = jar.get({ url: 'http://foo.com/foo/bar' }); 51 | cookies.should.have.length(1); 52 | cookies[0].should.equal(b); 53 | 54 | var jar = new Jar; 55 | var a = new Cookie('sid=1111; path=/foo/bar'); 56 | var b = new Cookie('sid=1234; path=/'); 57 | jar.add(a); 58 | jar.add(b); 59 | 60 | var cookies = jar.get({ url: 'http://foo.com/foo/bar' }); 61 | cookies.should.have.length(1); 62 | cookies[0].should.equal(a); 63 | 64 | var cookies = jar.get({ url: 'http://foo.com/' }); 65 | cookies.should.have.length(1); 66 | cookies[0].should.equal(b); 67 | 68 | var jar = new Jar; 69 | var a = new Cookie('sid=1111; path=/foo/bar'); 70 | var b = new Cookie('sid=3333; path=/foo/bar'); 71 | var c = new Cookie('pid=3333; path=/foo/bar'); 72 | var d = new Cookie('sid=2222; path=/foo/'); 73 | var e = new Cookie('sid=1234; path=/'); 74 | jar.add(a); 75 | jar.add(b); 76 | jar.add(c); 77 | jar.add(d); 78 | jar.add(e); 79 | 80 | var cookies = jar.get({ url: 'http://foo.com/foo/bar' }); 81 | cookies.should.have.length(2); 82 | cookies[0].should.equal(b); 83 | cookies[1].should.equal(c); 84 | 85 | var cookies = jar.get({ url: 'http://foo.com/foo/' }); 86 | cookies.should.have.length(1); 87 | cookies[0].should.equal(d); 88 | 89 | var cookies = jar.get({ url: 'http://foo.com/' }); 90 | cookies.should.have.length(1); 91 | cookies[0].should.equal(e); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 0.3.2 / 2011-11-15 3 | ================== 4 | 5 | * Added: allow for `button[type=submit]` elements to be clicked 6 | * node >= 0.4.x < 0.7.0. Closes #70 7 | * Fixed tests for 0.6.x 8 | 9 | 0.3.1 / 2011-07-19 10 | ================== 11 | 12 | * Fixed body on non-2xx response. Closes #56 13 | * Update jQuery to 1.6.2. Closes #43 14 | 15 | 0.3.0 / 2011-07-01 16 | ================== 17 | 18 | * Added ability to set User-Agent on the browser via `Browser#userAgent` [bnoguchi] 19 | * Fixed redirection from one host to another [bnoguchi] 20 | * Fixed http -> https redirects [bnoguchi] 21 | * Fixed support for uppercased form methods [bnoguchi] 22 | * Updated internals to use new node http agent [bnoguchi] 23 | 24 | 0.2.2 / 2011-06-28 25 | ================== 26 | 27 | * Added submit input values on submission [mhemesath] 28 | * Added default content-type for POST [bantic] 29 | * Added dev dependency for connect [bantic] 30 | * Added an option followRedirects to Browser [bantic] 31 | * Added HTTP 204 support. [bantic] 32 | 33 | 0.2.1 / 2011-05-16 34 | ================== 35 | 36 | * Added `Browser#delete()`. Closes #31 37 | * Added `Browser#put()` 38 | * Added devDependencies to package.json 39 | * Fixed `make test` 40 | 41 | 0.2.0 / 2011-04-13 42 | ================== 43 | 44 | * node 0.4.x 45 | * Fixed cookie support due to array 46 | * Fixed `session()` usage in tests 47 | * Fixed querystring related issues for 0.4.x 48 | * Fixed redirect `Location` support 49 | 50 | 0.1.1 / 2011-01-13 51 | ================== 52 | 53 | * Fixed jquery npm issue. Closes #20 54 | 55 | 0.1.0 / 2011-01-07 56 | ================== 57 | 58 | * Added `createBrowser(port, host)` support 59 | * Added `.include` modifier support to the `.text()` assertion method 60 | 61 | 0.0.8 / 2010-12-29 62 | ================== 63 | 64 | * Fixed potential portno issue 65 | 66 | 0.0.7 / 2010-12-29 67 | ================== 68 | 69 | * Added specificity prevalance when getting and filtered duplication when adding (cookie jar) 70 | * Added failing test of a cookie jar behavior that better resembles browers'. 71 | 72 | 0.0.6 / 2010-12-28 73 | ================== 74 | 75 | * Fixed problem with listen() firing on the same tick 76 | 77 | 0.0.5 / 2010-12-28 78 | ================== 79 | 80 | * Fixed; deferring all requests until the server listens 81 | * Added failing test 82 | 83 | 0.0.4 / 2010-12-28 84 | ================== 85 | 86 | * Fixed; defer the request until the server listen callback fires 87 | * Added failing test for deferred listen 88 | * Changed; removed useless port++ 89 | 90 | 0.0.3 / 2010-12-28 91 | ================== 92 | 93 | * Added; passing JSON obj as 2nd argument [guillermo] 94 | * Fixed; increment portno per Browser 95 | 96 | 0.0.2 / 2010-12-27 97 | ================== 98 | 99 | * Added Browser#text(locator) [guillermo] 100 | * Added failing test, which consists of the same form only with the input type="submit" being nested and not a direct descendent of the form. 101 | * Fixed; using `.parents()` instead of `parent()` to access a form that could be more than one level above. [guillermo] 102 | 103 | 0.0.1 / 2010-12-27 104 | ================== 105 | 106 | * Initial release 107 | -------------------------------------------------------------------------------- /examples/app.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var express = require('express') 7 | , MemoryStore = require('connect').session.MemoryStore; 8 | 9 | /** 10 | * Setup app. 11 | */ 12 | 13 | var app = module.exports = express.createServer( 14 | express.bodyDecoder() 15 | , express.cookieDecoder() 16 | , express.session({ store: new MemoryStore({ reapInterval: -1 })}) 17 | ); 18 | 19 | /** 20 | * Display login form. 21 | */ 22 | 23 | app.get('/login', function(req, res){ 24 | var msgs = req.flash('info'); 25 | res.send( 26 | (msgs.length ? '
  • ' + msgs[0] + '
' 27 | : req.session.user 28 | ? '
  • Already authenticated
' 29 | : '') 30 | + '
' 31 | + ' ' 32 | + ' ' 33 | + ' ' 34 | + '
'); 35 | }); 36 | 37 | /** 38 | * Authenticate user. 39 | */ 40 | 41 | app.post('/login', function(req, res){ 42 | var username = req.body.username 43 | , password = req.body.password; 44 | 45 | // Fake authentication / validation 46 | if ('tj' == username && 'tobi' == password) { 47 | req.flash('info', 'Successfully authenticated'); 48 | req.session.user = { name: 'tj' }; 49 | } else if (!username) { 50 | req.flash('info', 'Username required'); 51 | } else if (!password) { 52 | req.flash('info', 'Password required'); 53 | } else { 54 | req.flash('info', 'Authentication failed'); 55 | } 56 | 57 | res.redirect('/login'); 58 | }); 59 | 60 | var wizard = [ 61 | { 62 | show: function(req, res){ 63 | res.send('

Account

' 64 | + '
' 65 | + ' Username: ' 66 | + ' Email: ' 67 | + ' ' 68 | + '
'); 69 | }, 70 | 71 | post: function(req, res){ 72 | req.session.wizard = req.body; 73 | res.redirect('/wizard/page/1'); 74 | } 75 | }, 76 | 77 | { 78 | show: function(req, res){ 79 | res.send('

Details

' 80 | + '
' 81 | + ' ' 87 | + ' ' 88 | + '
'); 89 | }, 90 | 91 | post: function(req, res){ 92 | req.session.wizard.city = req.body.city; 93 | res.redirect('/wizard/page/2'); 94 | } 95 | }, 96 | 97 | { 98 | show: function(req, res){ 99 | var data = req.session.wizard; 100 | res.send('

Review

' 101 | + '
' 102 | + '
    ' 103 | + '
  • Name: ' + data.name + '
  • ' 104 | + '
  • Email: ' + data.email + '
  • ' 105 | + '
  • City: ' + data.city + '
  • ' 106 | + '
' 107 | + ' ' 108 | + '
'); 109 | }, 110 | 111 | post: function(req, res){ 112 | var data = req.session.wizard; 113 | res.send('

Registration Complete

' 114 | + '

Registration was completed with the following info:

' 115 | + '
    ' 116 | + '
  • Name: ' + data.name + '
  • ' 117 | + '
  • Email: ' + data.email + '
  • ' 118 | + '
  • City: ' + data.city + '
  • ' 119 | + '
'); 120 | } 121 | } 122 | ]; 123 | 124 | app.get('/wizard', function(req, res){ 125 | res.redirect('/wizard/page/0'); 126 | }); 127 | 128 | app.get('/wizard/page/:page', function(req, res){ 129 | wizard[req.params.page].show(req, res); 130 | }); 131 | 132 | app.post('/wizard/page/:page', function(req, res){ 133 | wizard[req.params.page].post(req, res); 134 | }); 135 | 136 | // Only listen on $ node app.js 137 | 138 | if (!module.parent) app.listen(3000); -------------------------------------------------------------------------------- /lib/jquery/index.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi - jQuery 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Module dependencies. 10 | */ 11 | 12 | var parse = require('url').parse 13 | , fill = require('./fill'); 14 | 15 | /** 16 | * Augment the given `jQuery` instance. 17 | * 18 | * @param {Browser} browser 19 | * @param {jQuery} $ 20 | * @api private 21 | */ 22 | 23 | module.exports = function(browser, $){ 24 | 25 | /** 26 | * Default selects to first option value. 27 | * 28 | * @api private 29 | */ 30 | 31 | $.fn.defaultSelectOptions = function(){ 32 | this.find('select').each(function(i, select){ 33 | var options = $(select).find('option'); 34 | 35 | // Number of selected options 36 | var selected = options.filter(function(i, option){ 37 | return option.getAttribute('selected'); 38 | }).length; 39 | 40 | // Select first 41 | if (!selected) { 42 | options.first().attr('selected', true); 43 | } 44 | }); 45 | }; 46 | 47 | /** 48 | * Select the given `options` by text or value attr. 49 | * 50 | * @param {Array} options 51 | * @api public 52 | */ 53 | 54 | $.fn.select = function(options){ 55 | if ('string' == typeof options) options = [options]; 56 | 57 | this.find('option').each(function(i, option){ 58 | // via text or value 59 | var selected = ~options.indexOf($(option).text()) 60 | || ~options.indexOf(option.getAttribute('value')); 61 | 62 | if (selected) { 63 | option.setAttribute('selected', 'selected'); 64 | } else { 65 | option.removeAttribute('selected'); 66 | } 67 | }) 68 | 69 | return this; 70 | }; 71 | 72 | /** 73 | * Click the first element with callback `fn(jQuery, res)` 74 | * when text/html or `fn(res)` otherwise. 75 | * 76 | * - requests a tag href 77 | * - requests form submit's parent form action 78 | * 79 | * @param {Function} fn 80 | * @api public 81 | */ 82 | 83 | $.fn.click = function(fn, locator){ 84 | var url 85 | , prop = 'element' 86 | , method = 'get' 87 | , locator = locator || this.selector 88 | , options = {}; 89 | 90 | switch (this[0].nodeName) { 91 | case 'A': 92 | prop = 'href'; 93 | url = this.attr('href'); 94 | break; 95 | case 'INPUT': 96 | case 'BUTTON': 97 | if ('submit' == this.attr('type')) { 98 | var form = this.parents('form').last(); 99 | form.defaultSelectOptions(); 100 | method = form.attr('method') || 'get'; 101 | url = form.attr('action') || parse($.browser.path).pathname; 102 | var body = form.serializeArray(); 103 | if (this.attr('name')) body.push({ name: this.attr('name'), value: this.val() }); 104 | body = $.param(body); 105 | if ('get' == method) { 106 | url += '?' + body; 107 | } else { 108 | options.body = body; 109 | options.headers = { 110 | 'Content-Type': 'application/x-www-form-urlencoded' 111 | }; 112 | } 113 | } 114 | break; 115 | } 116 | 117 | // Ensure url present 118 | if (!url) throw new Error('failed to click ' + locator + ', ' + prop + ' not present'); 119 | 120 | // Perform request 121 | browser[method.toLowerCase()](url, options, fn); 122 | 123 | return this; 124 | }; 125 | 126 | /** 127 | * Apply fill rules to the given `fields`. 128 | * 129 | * @param {Object} fields 130 | * @api public 131 | */ 132 | 133 | $.fn.fill = function(fields){ 134 | for (var locator in fields) { 135 | var val = fields[locator] 136 | , elems = browser.locate('select, input, textarea', locator) 137 | , name = elems[0].nodeName.toLowerCase(); 138 | fill[name]($, elems, val); 139 | } 140 | return this; 141 | }; 142 | 143 | /** 144 | * Submit this form with the given callback fn. 145 | * 146 | * @param {Function} fn 147 | * @api public 148 | */ 149 | 150 | $.fn.submit = function(fn, locator){ 151 | var submit = this.find(':submit'); 152 | if (submit.length) { 153 | submit.click(fn, locator); 154 | } else { 155 | $('') 156 | .appendTo(this) 157 | .click(fn, locator) 158 | .remove(); 159 | } 160 | }; 161 | }; 162 | -------------------------------------------------------------------------------- /test/assertions.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , should = require('should'); 9 | 10 | // Assert error 11 | 12 | function err(fn, msg){ 13 | var err; 14 | try { 15 | fn(); 16 | } catch (e) { 17 | should.equal(e.message, msg); 18 | return; 19 | } 20 | throw new Error('no exception thrown, expected "' + msg + '"'); 21 | } 22 | 23 | // Test app 24 | 25 | var app = express.createServer() 26 | , browser = tobi.createBrowser(app); 27 | 28 | app.get('/user/:id', function(req, res){ 29 | res.send('

Tobi

the ferret

'); 30 | }); 31 | 32 | app.get('/list', function(req, res){ 33 | res.send('

Wahoo

  • one
  • two
  • three
'); 34 | }); 35 | 36 | app.get('/attrs', function(req, res){ 37 | res.send('LearnBoost'); 38 | }); 39 | 40 | app.get('/classes', function(req, res){ 41 | res.send('
'); 42 | }); 43 | 44 | app.get('/form', function(req, res){ 45 | res.send('
' 46 | + '' 47 | + '' 48 | + '' 49 | + '' 50 | + '' 51 | + '' 56 | + '
'); 57 | }); 58 | 59 | // Tests 60 | 61 | exports['test .text()'] = function(done){ 62 | browser.get('/user/1', function(res, $){ 63 | $('p').prev().should.have.text('Tobi'); 64 | $('p').prev().should.have.text(/^To/); 65 | 66 | $('*').should.not.have.text('Tobi'); 67 | $('*').should.include.text('Tobi'); 68 | 69 | err(function(){ 70 | $('*').should.not.include.text('Tobi'); 71 | }, "expected [jQuery '*'] to not include text 'Tobi' within 'Tobithe ferretTobithe ferret'"); 72 | 73 | err(function(){ 74 | $('*').should.include.text('Shuppa'); 75 | }, "expected [jQuery '*'] to include text 'Shuppa' within 'Tobithe ferretTobithe ferret'"); 76 | 77 | err(function(){ 78 | $('h1').should.not.have.text('Tobi'); 79 | }, "expected [jQuery 'h1'] to not have text 'Tobi'"); 80 | 81 | err(function(){ 82 | $('h1').should.have.text('Raul'); 83 | }, "expected [jQuery 'h1'] to have text 'Raul', but has 'Tobi'"); 84 | 85 | err(function(){ 86 | $('h1').should.have.text(/^Raul/); 87 | }, "expected [jQuery 'h1'] to have text matching /^Raul/"); 88 | 89 | err(function(){ 90 | $('h1').should.not.have.text(/^To/); 91 | }, "expected [jQuery 'h1'] text 'Tobi' to not match /^To/"); 92 | 93 | done(); 94 | }); 95 | }; 96 | 97 | exports['test .many()'] = function(done){ 98 | browser.get('/list', function(res, $){ 99 | $('ul').should.have.many('li'); 100 | $('ul').should.not.have.many('rawr'); 101 | //$('ul').should.not.have.many('em'); 102 | 103 | // err(function(){ 104 | // $('ul').should.have.many('p'); 105 | // }, "expected [jQuery 'ul'] to have many 'p' tags, but has none"); 106 | // 107 | // err(function(){ 108 | // $('ul').should.have.many('em'); 109 | // }, "expected [jQuery 'ul'] to have many 'em' tags, but has one"); 110 | // 111 | // err(function(){ 112 | // $('ul').should.not.have.many('li'); 113 | // }, "expected [jQuery 'ul'] to not have many 'li' tags, but has 3"); 114 | 115 | done(); 116 | }); 117 | }; 118 | 119 | exports['test .one()'] = function(done){ 120 | browser.get('/list', function(res, $){ 121 | $('ul').should.not.have.one('li'); 122 | //$('ul > li:last-child').should.have.one('em'); 123 | 124 | err(function(){ 125 | $('ul').should.have.one('p'); 126 | }, "expected [jQuery 'ul'] to have one 'p' tag, but has none"); 127 | 128 | err(function(){ 129 | $('ul').should.have.one('li'); 130 | }, "expected [jQuery 'ul'] to have one 'li' tag, but has three"); 131 | 132 | err(function(){ 133 | $('*').should.have.one('p', 'Wahoos'); 134 | }, "expected [jQuery '* p'] to have text 'Wahoos', but has 'Wahoo'"); 135 | 136 | done(); 137 | }); 138 | }; 139 | 140 | exports['test .attr()'] = function(done){ 141 | browser.get('/attrs', function(res, $){ 142 | $('a').should.have.attr('href'); 143 | $('a').should.have.attr('href', 'http://learnboost.com'); 144 | $('a').should.not.have.attr('href', 'invalid'); 145 | $('a').should.not.have.attr('rawr'); 146 | 147 | err(function(){ 148 | $('a').should.not.have.attr('href'); 149 | }, "expected [jQuery 'a'] to not have attribute 'href', but has 'http://learnboost.com'"); 150 | 151 | err(function(){ 152 | $('a').should.not.have.attr('href', 'http://learnboost.com'); 153 | }, "expected [jQuery 'a'] to not have attribute 'href' with 'http://learnboost.com'"); 154 | 155 | err(function(){ 156 | $('a').should.have.attr('foo'); 157 | }, "expected [jQuery 'a'] to have attribute 'foo'"); 158 | 159 | err(function(){ 160 | $('a').should.have.attr('foo', 'bar'); 161 | }, "expected [jQuery 'a'] to have attribute 'foo'"); 162 | 163 | err(function(){ 164 | $('a').should.have.attr('href', 'http://tobi.com'); 165 | }, "expected [jQuery 'a'] to have attribute 'href' with 'http://tobi.com', but has 'http://learnboost.com'"); 166 | 167 | done(); 168 | }); 169 | }; 170 | 171 | exports['test .class()'] = function(done){ 172 | browser.get('/classes', function(res, $){ 173 | $('div').should.have.class('foo'); 174 | $('div').should.have.class('bar'); 175 | $('div').should.have.class('baz'); 176 | $('div').should.not.have.class('rawr'); 177 | 178 | err(function(){ 179 | $('div').should.not.have.class('foo'); 180 | }, "expected [jQuery 'div'] to not have class 'foo'"); 181 | 182 | err(function(){ 183 | $('div').should.have.class('rawr'); 184 | }, "expected [jQuery 'div'] to have class 'rawr', but has 'foo bar baz'"); 185 | 186 | done(); 187 | }); 188 | }; 189 | 190 | exports['test .enabled / .disabled'] = function(done){ 191 | browser.get('/form', function(res, $){ 192 | $('input[name="user[name]"]').should.be.enabled; 193 | $('input[name="user[email]"]').should.be.disabled; 194 | 195 | err(function(){ 196 | $('input[name="user[email]"]').should.be.enabled; 197 | }, "expected [jQuery 'input[name=\"user[email]\"]'] to be enabled"); 198 | 199 | err(function(){ 200 | $('input[name="user[name]"]').should.be.disabled; 201 | }, "expected [jQuery 'input[name=\"user[name]\"]'] to be disabled"); 202 | 203 | done(); 204 | }); 205 | }; 206 | 207 | exports['test .checked'] = function(done){ 208 | browser.get('/form', function(res, $){ 209 | $('input[name="user[agreement]"]').should.be.checked; 210 | $('input[name="user[agreement2]"]').should.not.be.checked; 211 | 212 | err(function(){ 213 | $('input[name="user[agreement2]"]').should.be.checked; 214 | }, "expected [jQuery 'input[name=\"user[agreement2]\"]'] to be checked"); 215 | 216 | err(function(){ 217 | $('input[name="user[agreement]"]').should.not.be.checked; 218 | }, "expected [jQuery 'input[name=\"user[agreement]\"]'] to not be checked"); 219 | 220 | done(); 221 | }); 222 | }; 223 | 224 | exports['test .selected'] = function(done){ 225 | browser.get('/form', function(res, $){ 226 | $('select > option:nth-child(3)').should.be.selected; 227 | $('select > option:nth-child(2)').should.not.be.selected; 228 | 229 | err(function(){ 230 | $('select > option:nth-child(2)').should.be.selected; 231 | }, "expected [jQuery 'select > option:nth-child(2)'] to be selected"); 232 | 233 | err(function(){ 234 | $('select > option:nth-child(3)').should.not.be.selected; 235 | }, "expected [jQuery 'select > option:nth-child(3)'] to not be selected"); 236 | 237 | done(); 238 | }); 239 | }; 240 | 241 | exports['test .id()'] = function(done){ 242 | browser.get('/attrs', function(res, $){ 243 | $('a').should.have.id('lb'); 244 | $('a').should.not.have.id('foo'); 245 | 246 | err(function(){ 247 | $('a').should.have.id('rawr'); 248 | }, "expected [jQuery 'a'] to have id 'rawr', but has 'lb'"); 249 | 250 | err(function(){ 251 | $('a').should.not.have.id('lb'); 252 | }, "expected [jQuery 'a'] to not have id 'lb'"); 253 | 254 | done(); 255 | }); 256 | }; 257 | 258 | exports['test .status()'] = function(done){ 259 | browser.get('/attrs', function(res, $){ 260 | res.should.have.status(200); 261 | 262 | err(function(){ 263 | res.should.have.status(404); 264 | }, "expected response code of 404 'Not Found', but got 200 'OK'"); 265 | 266 | done(); 267 | }); 268 | }; 269 | 270 | exports['test .header()'] = function(done){ 271 | browser.get('/attrs', function(res, $){ 272 | res.should.have.header('Content-Type', 'text/html; charset=utf-8'); 273 | done(); 274 | }); 275 | }; 276 | 277 | exports.after = function(){ 278 | app.close(); 279 | }; -------------------------------------------------------------------------------- /lib/assertions/should.js: -------------------------------------------------------------------------------- 1 | 2 | /*! 3 | * Tobi - assertions - should 4 | * Copyright(c) 2010 LearnBoost 5 | * MIT Licensed 6 | */ 7 | 8 | /** 9 | * Module dependencies. 10 | */ 11 | 12 | var Assertion = require('should').Assertion 13 | , statusCodes = require('http').STATUS_CODES 14 | , j = function(elem){ return '[jQuery ' + i(elem.selector.replace(/^ *\* */, '')) + ']'; } 15 | , i = require('util').inspect; 16 | 17 | /** 18 | * Number strings. 19 | */ 20 | 21 | var nums = [ 22 | 'none' 23 | , 'one' 24 | , 'two' 25 | , 'three' 26 | , 'four' 27 | , 'five' 28 | ]; 29 | 30 | /** 31 | * Return string representation for `n`. 32 | * 33 | * @param {Number} n 34 | * @return {String} 35 | * @api private 36 | */ 37 | 38 | function n(n) { return nums[n] || n; } 39 | 40 | /** 41 | * Assert text as `str` or a `RegExp`. 42 | * 43 | * @param {String|RegExp} str 44 | * @return {Assertion} for chaining 45 | * @api public 46 | */ 47 | 48 | Assertion.prototype.text = function(str){ 49 | var elem = this.obj 50 | , text = elem.text() 51 | , include = this.includes; 52 | 53 | if (str instanceof RegExp) { 54 | this.assert( 55 | str.test(text) 56 | , 'expected ' + j(elem)+ ' to have text matching ' + i(str) 57 | , 'expected ' + j(elem) + ' text ' + i(text) + ' to not match ' + i(str)); 58 | } else if (include) { 59 | this.assert( 60 | ~text.indexOf(str) 61 | , 'expected ' + j(elem) + ' to include text ' + i(str) + ' within ' + i(text) 62 | , 'expected ' + j(elem) + ' to not include text ' + i(str) + ' within ' + i(text)); 63 | } else { 64 | this.assert( 65 | str == text 66 | , 'expected ' + j(elem) + ' to have text ' + i(str) + ', but has ' + i(text) 67 | , 'expected ' + j(elem) + ' to not have text ' + i(str)); 68 | } 69 | 70 | return this; 71 | }; 72 | 73 | /** 74 | * Assert that many child elements are present via `selector`. 75 | * When negated, <= 1 is a valid length. 76 | * 77 | * @param {String} selector 78 | * @return {Assertion} for chaining 79 | * @api public 80 | */ 81 | 82 | Assertion.prototype.many = function(selector){ 83 | var elem = this.obj 84 | , elems = elem.find(selector) 85 | , len = elems.length; 86 | 87 | this.assert( 88 | this.negate ? len > 1 : len 89 | , 'expected ' + j(elem) + ' to have many ' + i(selector) + ' tags, but has ' + n(len) 90 | , 'expected ' + j(elem) + ' to not have many ' + i(selector) + ' tags, but has ' + n(len)); 91 | 92 | return this; 93 | }; 94 | 95 | /** 96 | * Assert that one child element is present via `selector` 97 | * with optional `text` assertion.. 98 | * 99 | * @param {String} selector 100 | * @param {String} text 101 | * @return {Assertion} for chaining 102 | * @api public 103 | */ 104 | 105 | Assertion.prototype.one = function(selector, text){ 106 | var elem = this.obj 107 | , elems = elem.find(selector) 108 | , len = elems.length; 109 | 110 | this.assert( 111 | 1 == len 112 | , 'expected ' + j(elem) + ' to have one ' + i(selector) + ' tag, but has ' + n(len) 113 | , 'expected ' + j(elem) + ' to not have one ' + i(selector) + ' tag, but has ' + n(len)); 114 | 115 | if (undefined != text) { 116 | elems.should.have.text(text); 117 | } 118 | 119 | return this; 120 | }; 121 | 122 | /** 123 | * Assert existance attr `key` with optional `val`. 124 | * 125 | * @param {String} key 126 | * @param {String} val 127 | * @return {Assertion} for chaining 128 | * @api public 129 | */ 130 | 131 | Assertion.prototype.attr = function(key, val){ 132 | var elem = this.obj 133 | , attr = elem.attr(key); 134 | 135 | if (!val || (val && !this.negate)) { 136 | this.assert( 137 | attr.length 138 | , 'expected ' + j(elem) + ' to have attribute ' + i(key) 139 | , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ', but has ' + i(attr)); 140 | } 141 | 142 | if (val) { 143 | this.assert( 144 | val == attr 145 | , 'expected ' + j(elem) + ' to have attribute ' + i(key) + ' with ' + i(val) + ', but has ' + i(attr) 146 | , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ' with ' + i(val)); 147 | } 148 | 149 | return this; 150 | }; 151 | 152 | /** 153 | * Assert presence of the given class `name`. 154 | * 155 | * @param {String} name 156 | * @return {Assertion} for chaining 157 | * @api public 158 | */ 159 | 160 | Assertion.prototype.class = function(name){ 161 | var elem = this.obj; 162 | 163 | this.assert( 164 | elem.hasClass(name) 165 | , 'expected ' + j(elem) + ' to have class ' + i(name) + ', but has ' + i(elem.attr('class')) 166 | , 'expected ' + j(elem) + ' to not have class ' + i(name)); 167 | 168 | return this; 169 | }; 170 | 171 | /** 172 | * Assert that header `field` has the given `val`. 173 | * 174 | * @param {String} field 175 | * @param {String} val 176 | * @return {Assertion} for chaining 177 | * @api public 178 | */ 179 | 180 | Assertion.prototype.header = function(field, val){ 181 | this.obj.should.have.property('headers'); 182 | this.obj.headers.should.have.property(field.toLowerCase(), val); 183 | return this; 184 | }; 185 | 186 | /** 187 | * Assert `.statusCode` of `code`. 188 | * 189 | * @param {Number} code 190 | * @return {Assertion} for chaining 191 | * @api public 192 | */ 193 | 194 | Assertion.prototype.status = function(code){ 195 | this.obj.should.have.property('statusCode'); 196 | var status = this.obj.statusCode; 197 | 198 | this.assert( 199 | code == status 200 | , 'expected response code of ' + code + ' ' + i(statusCodes[code]) 201 | + ', but got ' + status + ' ' + i(statusCodes[status]) 202 | , 'expected to not respond with ' + code + ' ' + i(statusCodes[code])); 203 | 204 | return this; 205 | }; 206 | 207 | /** 208 | * Assert id attribute. 209 | * 210 | * @param {String} val 211 | * @return {Assertion} for chaining 212 | * @api public 213 | */ 214 | 215 | Assertion.prototype.id = attr('id'); 216 | 217 | /** 218 | * Assert title attribute. 219 | * 220 | * @param {String} val 221 | * @return {Assertion} for chaining 222 | * @api public 223 | */ 224 | 225 | Assertion.prototype.title = attr('title'); 226 | 227 | /** 228 | * Assert alt attribute. 229 | * 230 | * @param {String} val 231 | * @return {Assertion} for chaining 232 | * @api public 233 | */ 234 | 235 | Assertion.prototype.alt = attr('alt'); 236 | 237 | /** 238 | * Assert href attribute. 239 | * 240 | * @param {String} val 241 | * @return {Assertion} for chaining 242 | * @api public 243 | */ 244 | 245 | Assertion.prototype.href = attr('href'); 246 | 247 | /** 248 | * Assert src attribute. 249 | * 250 | * @param {String} val 251 | * @return {Assertion} for chaining 252 | * @api public 253 | */ 254 | 255 | Assertion.prototype.src = attr('src'); 256 | 257 | /** 258 | * Assert rel attribute. 259 | * 260 | * @param {String} val 261 | * @return {Assertion} for chaining 262 | * @api public 263 | */ 264 | 265 | Assertion.prototype.rel = attr('rel'); 266 | 267 | /** 268 | * Assert media attribute. 269 | * 270 | * @param {String} val 271 | * @return {Assertion} for chaining 272 | * @api public 273 | */ 274 | 275 | Assertion.prototype.media = attr('media'); 276 | 277 | /** 278 | * Assert name attribute. 279 | * 280 | * @param {String} val 281 | * @return {Assertion} for chaining 282 | * @api public 283 | */ 284 | 285 | Assertion.prototype.name = attr('name'); 286 | 287 | /** 288 | * Assert action attribute. 289 | * 290 | * @param {String} val 291 | * @return {Assertion} for chaining 292 | * @api public 293 | */ 294 | 295 | Assertion.prototype.action = attr('action'); 296 | 297 | /** 298 | * Assert method attribute. 299 | * 300 | * @param {String} val 301 | * @return {Assertion} for chaining 302 | * @api public 303 | */ 304 | 305 | Assertion.prototype.method = attr('method'); 306 | 307 | /** 308 | * Assert value attribute. 309 | * 310 | * @param {String} val 311 | * @return {Assertion} for chaining 312 | * @api public 313 | */ 314 | 315 | Assertion.prototype.value = attr('value'); 316 | 317 | /** 318 | * Assert enabled. 319 | * 320 | * @return {Assertion} for chaining 321 | * @api public 322 | */ 323 | 324 | Assertion.prototype.__defineGetter__('enabled', function(){ 325 | var elem = this.obj 326 | , disabled = elem.attr('disabled'); 327 | 328 | this.assert( 329 | !disabled 330 | , 'expected ' + j(elem) + ' to be enabled' 331 | , ''); 332 | 333 | return this; 334 | }); 335 | 336 | /** 337 | * Assert disabled. 338 | * 339 | * @return {Assertion} for chaining 340 | * @api public 341 | */ 342 | 343 | Assertion.prototype.__defineGetter__('disabled', function(){ 344 | var elem = this.obj 345 | , disabled = elem.attr('disabled'); 346 | 347 | this.assert( 348 | disabled 349 | , 'expected ' + j(elem) + ' to be disabled' 350 | , ''); 351 | 352 | return this; 353 | }); 354 | 355 | /** 356 | * Assert checked. 357 | * 358 | * @return {Assertion} for chaining 359 | * @api public 360 | */ 361 | 362 | Assertion.prototype.__defineGetter__('checked', bool('checked')); 363 | 364 | /** 365 | * Assert selected. 366 | * 367 | * @return {Assertion} for chaining 368 | * @api public 369 | */ 370 | 371 | Assertion.prototype.__defineGetter__('selected', bool('selected')); 372 | 373 | /** 374 | * Generate a boolean assertion function for the given attr `name`. 375 | * 376 | * @param {String} name 377 | * @return {Function} 378 | * @api private 379 | */ 380 | 381 | function bool(name) { 382 | return function(){ 383 | var elem = this.obj; 384 | 385 | this.assert( 386 | elem.attr(name) 387 | , 'expected ' + j(elem) + ' to be ' + name 388 | , 'expected ' + j(elem) + ' to not be ' + name); 389 | 390 | return this; 391 | } 392 | } 393 | 394 | /** 395 | * Generate an attr assertion function for the given attr `name`. 396 | * 397 | * @param {String} name 398 | * @return {Function} 399 | * @api private 400 | */ 401 | 402 | function attr(name) { 403 | return function(expected){ 404 | var elem = this.obj 405 | , val = elem.attr(name); 406 | 407 | this.assert( 408 | expected == val 409 | , 'expected ' + j(elem) + ' to have ' + name + ' ' + i(expected) + ', but has ' + i(val) 410 | , 'expected ' + j(elem) + ' to not have ' + name + ' ' + i(expected)); 411 | 412 | return this; 413 | } 414 | } 415 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # Tobi 3 | 4 | Expressive server-side functional testing with jQuery and [jsdom](https://github.com/tmpvar/jsdom). 5 | 6 | Tobi allows you to test your web application as if it were a browser. Interactions with your app are performed via jsdom, htmlparser, and jQuery, in combination with Tobi's Cookie Jar, provides a natural JavaScript API for traversing, manipulating and asserting the DOM, and session based browsing. 7 | 8 | ## Example 9 | 10 | In the example below, we have an http server or express app `require()`ed, and we simply create new tobi `Browser` for that app to test against. Then we `GET /login`, receiving a response to assert headers, status codes etc, and the `$` jQuery context. 11 | 12 | We can then use regular css selectors to grab the form, we use tobi's `.fill()` method to fill some inputs (supports textareas, checkboxes, radios, etc), then we proceed to submitting the form, again receiving a response and the jQuery context. 13 | 14 | var tobi = require('tobi') 15 | , app = require('./my/app') 16 | , browser = tobi.createBrowser(app); 17 | 18 | browser.get('/login', function(res, $){ 19 | $('form') 20 | .fill({ username: 'tj', password: 'tobi' }) 21 | .submit(function(res, $){ 22 | res.should.have.status(200); 23 | res.should.have.header('Content-Length'); 24 | res.should.have.header('Content-Type', 'text/html; charset=utf-8'); 25 | $('ul.messages').should.have.one('li', 'Successfully authenticated'); 26 | browser.get('/login', function(res, $){ 27 | res.should.have.status(200); 28 | $('ul.messages').should.have.one('li', 'Already authenticated'); 29 | // We are finished testing, close the server 30 | app.close(); 31 | }); 32 | }); 33 | }); 34 | 35 | ## Browser 36 | 37 | Tobi provides the `Browser` object, created via `tobi.createBrowser(app)`, where `app` is a node `http.Server`, so for example Connect or Express apps will work just fine. There is no need to invoke `listen()` as this is handled by Tobi, and requests will be deferred until the server is listening. 38 | 39 | Alternatively you may pass a `port` and `host` to `createBrowser()`, for example: 40 | 41 | var browser = tobi.createBrowser(80, 'lb.dev'); 42 | 43 | ### Evaluate External Resources 44 | 45 | To evaluate script tags simply pass the `{ external: true }` option: 46 | 47 | var browser = tobi.createBrowser(app, { external: true }); 48 | 49 | ### Browser#get() 50 | 51 | Perform a `GET` request with optional `options` containing headers, etc: 52 | 53 | browser.get('/login', function(res, $){ 54 | 55 | }); 56 | 57 | With options: 58 | 59 | browser.get('/login', { headers: { ... }}, function(res, $){ 60 | 61 | }); 62 | 63 | Aliased as `visit`, and `open`. 64 | 65 | ### Browser#post() 66 | 67 | Perform a `POST` request with optional `options` containing headers, body etc: 68 | 69 | browser.post('/login', function(res, $){ 70 | 71 | }); 72 | 73 | With options: 74 | 75 | browser.post('/login', { body: 'foo=bar' }, function(res, $){ 76 | 77 | }); 78 | 79 | ### Browser#back() 80 | 81 | `GET` the previous page: 82 | 83 | browser.get('/', function(){ 84 | // on / 85 | browser.get('/foo', function(){ 86 | // on /foo 87 | browser.back(function(){ 88 | // on / 89 | }); 90 | }); 91 | }); 92 | 93 | ## Browser locators 94 | 95 | Locators are extended extend selectors, the rules are as following: 96 | 97 | - element text 98 | - element id 99 | - element value 100 | - css selector 101 | 102 | These rules apply to all `Browser` related methods such as `click()`, `fill()`, `type()` etc. Provided the following markup: 103 | 104 |
105 | 106 |
107 | 108 | The following locators will match the input: 109 | 110 | .click('Login'); 111 | .click('form-login'); 112 | .click('input[type=submit]'); 113 | .click(':submit'); 114 | 115 | ### Browser#click(locator[, fn]) 116 | 117 | Tobi allows you to `click()` `a` elements, `button[type=submit]` elements, and `input[type=submit]` elements in order to submit a form, or request a url. 118 | 119 | Submitting a form: 120 | 121 | browser.click('Login', function(res, $){ 122 | 123 | }); 124 | 125 | Submitting with jQuery (no locators): 126 | 127 | $('form :submit').click(function(res, $){ 128 | 129 | }); 130 | 131 | Clicking a link: 132 | 133 | browser.click('Register Account', function(res, $){ 134 | 135 | }); 136 | 137 | Clicking with jQuery (no locators): 138 | 139 | $('a.register', function(res, $){ 140 | 141 | }); 142 | 143 | ### Browser#submit(locator|fn, fn) 144 | 145 | Submit the first form in context: 146 | 147 | browser.submit(function(res, $){ 148 | 149 | }); 150 | 151 | browser.submit(function(){ 152 | 153 | }); 154 | 155 | ### Browser#type(locator, str) 156 | 157 | "Type" the given _str_: 158 | 159 | browser 160 | .type('username', 'tj') 161 | .type('password', 'foobar'); 162 | 163 | 164 | ### Browser#{check,uncheck}(locator) 165 | 166 | Check or uncheck the given _locator_: 167 | 168 | browser 169 | .check('agreement') 170 | .uncheck('agreement'); 171 | 172 | ### Browser#select(locator, options) 173 | 174 | Select the given option or options: 175 | 176 | browser 177 | .select('colors', 'Red') 178 | .select('colors', ['Red', 'Green']); 179 | 180 | ### Browser#fill(locator, fields) 181 | 182 | Fill the given _fields_, supporting all types of inputs. For example we might have the following form: 183 | 184 |
185 | 186 | 187 | 188 | 189 | 194 | 195 | 200 |
201 | 202 | Which can be filled using locators: 203 | 204 | browser 205 | .fill({ 206 | 'user[name]': 'tj' 207 | , 'user[email]': 'tj@learnboost.com' 208 | , 'user[agreement]': true 209 | , 'user[digest]': 'Daily' 210 | , 'user[favorite-colors]': ['red', 'Green'] 211 | }).submit(function(){ 212 | 213 | }); 214 | 215 | With jQuery: 216 | 217 | $('form') 218 | .fill({ 219 | 'user[name]': 'tj' 220 | , 'user[favorite-colors]': 'red' 221 | }).submit(function(){ 222 | 223 | }); 224 | 225 | ### Browser#text(locator) 226 | 227 | Return text at the given locator. For example if we have the form option somewhere in our markup: 228 | 229 | 230 | 231 | We can invoke `browser.text('once')` returning "Once per day". 232 | 233 | ### Browser#{context,within}(selector, fn) 234 | 235 | Alter the browser context for the duration of the given callback `fn`. For example if you have several forms on a page, an wish to focus on one: 236 | 237 |
240 | 241 |
242 | 243 |
244 | 245 | Example test using contexts: 246 | 247 | browser.get('/search', function(res, $){ 248 | 249 | // Global context has 2 forms 250 | $('form').should.have.length(2); 251 | 252 | // Focus on the second div 253 | browser.within('div:nth-child(2)', function(){ 254 | 255 | // We now have one form, and no direct input children 256 | $('> form').should.have.length(1); 257 | $('> input').should.have.length(0); 258 | 259 | // Focus on the form, we now have a single direct input child 260 | browser.within('form', function(){ 261 | $('> form').should.have.length(0); 262 | $('> input').should.have.length(1); 263 | }); 264 | 265 | // Back to our div focus, we have one form again 266 | $('> form').should.have.length(1); 267 | $('> input').should.have.length(0); 268 | 269 | // Methods such as .type() etc work with contexts 270 | browser 271 | .type('query', 'foo bar') 272 | .submit(function(res){ 273 | 274 | }); 275 | }); 276 | 277 | // Back to global context 278 | $('form').should.have.length(2); 279 | }); 280 | 281 | ## Assertions 282 | 283 | Tobi extends the [should.js](http://github.com/visionmedia/should.js) assertion library to provide you with DOM and response related assertion methods. 284 | 285 | ### Assertion#text(str|regexp) 286 | 287 | Assert element text via regexp or string: 288 | 289 | elem.should.have.text('foo bar'); 290 | elem.should.have.text(/^foo/); 291 | elem.should.not.have.text('rawr'); 292 | 293 | When asserting a descendant's text amongst a heap of elements, we can utilize the `.include` modifier: 294 | 295 | $('*').should.include.text('My Site'); 296 | 297 | ### Assertion#many(selector) 298 | 299 | Assert that one or more of the given selector is present: 300 | 301 | ul.should.have.many('li'); 302 | 303 | ### Assertion#one(selector) 304 | 305 | Assert that one of the given selector is present: 306 | 307 | p.should.have.one('input'); 308 | 309 | ### Assertion#attr(key[, val]) 310 | 311 | Assert that the given _key_ exists, with optional _val_: 312 | 313 | p.should.not.have.attr('href'); 314 | a.should.have.attr('href'); 315 | a.should.have.attr('href', 'http://learnboost.com'); 316 | 317 | Shortcuts are also provided: 318 | 319 | - id() 320 | - title() 321 | - href() 322 | - alt() 323 | - src() 324 | - rel() 325 | - media() 326 | - name() 327 | - action() 328 | - method() 329 | - value() 330 | - enabled 331 | - disabled 332 | - checked 333 | - selected 334 | 335 | For example: 336 | 337 | form.should.have.id('user-edit'); 338 | form.should.have.action('/login'); 339 | form.should.have.method('post'); 340 | checkbox.should.be.enabled; 341 | checkbox.should.be.disabled; 342 | option.should.be.selected; 343 | option.should.not.be.selected; 344 | 345 | ### Assertion#class(name) 346 | 347 | Assert that the element has the given class _name_. 348 | 349 | form.should.have.class('user-edit'); 350 | 351 | ### Assertion#status(code) 352 | 353 | Assert response status code: 354 | 355 | res.should.have.status(200); 356 | res.should.not.have.status(500); 357 | 358 | ### Assertion#header(field[, val]) 359 | 360 | Assert presence of response header _field_ and optional _val_: 361 | 362 | res.should.have.header('Content-Length'); 363 | res.should.have.header('Content-Type', 'text/html'); 364 | 365 | ## Testing 366 | 367 | Install the deps: 368 | 369 | $ npm install 370 | 371 | and execute: 372 | 373 | $ make test 374 | 375 | ## WWTD 376 | 377 | What Would Tobi Do: 378 | 379 | ![Tobi](http://sphotos.ak.fbcdn.net/hphotos-ak-snc3/hs234.snc3/22173_446973930292_559060292_10921426_7238463_n.jpg) 380 | 381 | ## License 382 | 383 | (The MIT License) 384 | 385 | Copyright (c) 2010 LearnBoost <dev@learnboost.com> 386 | 387 | Permission is hereby granted, free of charge, to any person obtaining 388 | a copy of this software and associated documentation files (the 389 | 'Software'), to deal in the Software without restriction, including 390 | without limitation te rights to use, copy, modify, merge, publish, 391 | distribute, sublicense, and/or sell copies of the Software, and to 392 | permit persons to whom the Software is furnished to do so, subject to 393 | the following conditions: 394 | 395 | The above copyright notice and this permission notice shall be 396 | included in all copies or substantial portions of the Software. 397 | 398 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 399 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 400 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 401 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 402 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 403 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 404 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/browser.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Tobi - Browser 3 | * Copyright(c) 2010 LearnBoost 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * Module dependencies. 9 | */ 10 | 11 | var EventEmitter = require('events').EventEmitter 12 | , Cookie = require('./cookie') 13 | , CookieJar = require('./cookie/jar') 14 | , jsdom = require('jsdom') 15 | , jQuery = require('./jquery/core') 16 | , url = require('url') 17 | , https = require('https') 18 | , http = require('http'); 19 | 20 | /** 21 | * Starting portno. 22 | */ 23 | 24 | var startingPort = 9000; 25 | 26 | /** 27 | * Initialize a new `Browser` with the given `html`, `server` 28 | * or `port` and `host`. 29 | * 30 | * Options: 31 | * 32 | * - `external` enable fetching and evaluation of external resources [false] 33 | * 34 | * @param {String|http.Server|Number} html 35 | * @param {Object|String} options 36 | * @param {Object} c 37 | * @api public 38 | */ 39 | 40 | var Browser = module.exports = exports = function Browser(html, options, c) { 41 | var host, port; 42 | // Host as second arg 43 | if ('string' == typeof options) host = options, options = null; 44 | // Options as third arg 45 | if (c) options = c; 46 | 47 | // Initialize 48 | options = options || {}; 49 | this.external = options.external; 50 | this.history = []; 51 | this.cookieJar = new CookieJar; 52 | this.followRedirects = true; 53 | 54 | // Client types 55 | if ('number' == typeof html) { 56 | port = html; 57 | this.port = html; 58 | this.host = host; 59 | } else if ('string' == typeof html) { 60 | this.parse(html); 61 | } else { 62 | this.server = html; 63 | } 64 | this.https = (port === 443); 65 | }; 66 | 67 | /** 68 | * Inherit from `EventEmitter.prototype`. 69 | */ 70 | 71 | Browser.prototype.__proto__ = EventEmitter.prototype; 72 | 73 | /** 74 | * Parse the given `html` and populate: 75 | * 76 | * - `.source` 77 | * - `.window` 78 | * - `.jQuery` 79 | * 80 | * @param {String} html 81 | * @api public 82 | */ 83 | 84 | Browser.prototype.parse = function(html){ 85 | var options = {}; 86 | if (!this.external) { 87 | options.features = { 88 | FetchExternalResources: false 89 | , ProcessExternalResources: false 90 | }; 91 | } 92 | this.source = html; 93 | this.window = jsdom.jsdom(wrap(html), null, options).createWindow(); 94 | this.window.navigator = { userAgent: 'node' }; 95 | this.jQuery = jQuery.create(this.window); 96 | this.jQuery.browser = this.jQuery.fn.browser = this; 97 | require('./jquery')(this, this.jQuery); 98 | this.context = this.jQuery('*'); 99 | }; 100 | 101 | /** 102 | * Set the jQuery context for the duration of `fn()` to `selector`. 103 | * 104 | * @param {String} selector 105 | * @param {Function} fn 106 | * @return {Browser} for chaining 107 | * @api public 108 | */ 109 | 110 | Browser.prototype.within = 111 | Browser.prototype.context = function(selector, fn){ 112 | var prev = this.context; 113 | this.context = this.context.find(selector); 114 | fn(); 115 | this.context = prev; 116 | return this; 117 | }; 118 | 119 | /** 120 | * Finds or creates/caches a Browser instance associated with the port 121 | * and hostname of `uri`. If a new Browser is created, then the source 122 | * Browser's User-Agent (if it is set) is copied to the new Browser. 123 | * 124 | * @param {Object} uri is an object returned from `require('url').parse(someUrl)` 125 | * @return {Browser} the Browser associated with uri 126 | * @api public 127 | */ 128 | 129 | Browser.prototype.hostBrowser = function(uri) { 130 | var otherHostname = uri.hostname 131 | , port = parseInt(uri.port,10) || (uri.protocol === 'https:' ? 443 : (this.port || 80)) 132 | , browsers = Browser.browsers 133 | , otherBrowser; 134 | if (!browsers) { 135 | browsers = Browser.browsers = {}; 136 | } 137 | if (!browsers[this.host]) { 138 | browsers[this.host] = this; 139 | } 140 | otherBrowser = browsers[otherHostname]; 141 | if (!otherBrowser) { 142 | otherBrowser = 143 | browsers[otherHostname] = new Browser(port, otherHostname); 144 | otherBrowser.userAgent = this.userAgent; 145 | } 146 | return otherBrowser; 147 | }; 148 | 149 | /** 150 | * Request `path` with `method` and callback `fn(jQuery)`. 151 | * 152 | * @param {String} path 153 | * @param {String} method 154 | * @param {Object} options 155 | * @param {Function} fn 156 | * @return {Browser} for chaining 157 | * @api public 158 | */ 159 | 160 | Browser.prototype.request = function(method, path, options, fn, saveHistory){ 161 | var self = this 162 | , server = this.server 163 | , host = this.host || '127.0.0.1' 164 | , headers = options.headers || {}; 165 | 166 | // Ensure that server is ready to take connections 167 | if (server && !server.fd){ 168 | (server.__deferred = server.__deferred || []) 169 | .push(arguments); 170 | if (!server.__started) { 171 | server.listen(server.__port = ++startingPort, host, function(){ 172 | process.nextTick(function(){ 173 | server.__deferred.forEach(function(args){ 174 | self.request.apply(self, args); 175 | }); 176 | }); 177 | }); 178 | server.__started = true; 179 | } 180 | return; 181 | } 182 | 183 | var uri, otherHostname, otherBrowser; 184 | if (0 == path.indexOf('http')) { 185 | // If we have a full uri, not a uri path, then convert the full uri to a path 186 | uri = url.parse(path); 187 | path = uri.pathname + (uri.search || ''); 188 | otherHostname = uri.hostname; 189 | if (otherHostname && 190 | (otherHostname !== 'undefined') && 191 | (otherHostname !== host)) { 192 | otherBrowser = this.hostBrowser(uri); 193 | return otherBrowser.request(method, path, options, fn, saveHistory); 194 | } 195 | } 196 | 197 | 198 | // Save history 199 | if (false !== saveHistory) this.history.push(path); 200 | 201 | // Cookies 202 | var cookies = this.cookieJar.cookieString({ url: path }); 203 | if (cookies) headers.Cookie = cookies; 204 | 205 | // User-Agent 206 | if (this.userAgent) headers['User-Agent'] = this.userAgent; 207 | 208 | // Request body 209 | if (options.body) { 210 | headers['Content-Length'] = options.body.length; 211 | headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded'; 212 | } 213 | 214 | // Request 215 | var req = (this.https ? https : http).request({ 216 | method: method 217 | , path: path 218 | , port: this.host ? this.port : (server && server.__port) 219 | , host: this.host 220 | , headers: headers 221 | }); 222 | req.on('response', function(res){ 223 | var status = res.statusCode 224 | , buf = ''; 225 | 226 | // Cookies 227 | if (res.headers['set-cookie']) { 228 | res.headers['set-cookie'].forEach(function(cookie) { 229 | self.cookieJar.add(new Cookie(cookie)); 230 | }); 231 | } 232 | 233 | // Redirect 234 | if (status >= 300 && status < 400) { 235 | var location = res.headers.location 236 | , uri = url.parse(location) 237 | , path = uri.pathname + (uri.search || ''); 238 | otherHostname = uri.hostname; 239 | if (otherHostname && 240 | (otherHostname !== 'undefined') && 241 | (otherHostname !== self.host)) { 242 | self = self.hostBrowser(uri); 243 | } 244 | self.emit('redirect', location); 245 | if (self.followRedirects) { 246 | self.request('GET', path, {}, fn); 247 | } else { 248 | return fn(res); 249 | } 250 | // Other 251 | } else { 252 | var contentType = res.headers['content-type']; 253 | 254 | if (!contentType) return fn(res); 255 | 256 | // JSON support 257 | if (~contentType.indexOf('json')) { 258 | res.body = ''; 259 | res.on('data', function(chunk){ res.body += chunk; }); 260 | res.on('end', function(){ 261 | try { 262 | res.body = JSON.parse(res.body); 263 | fn(res, res.body); 264 | } catch (err) { 265 | self.emit('error', err); 266 | } 267 | }); 268 | return; 269 | } 270 | 271 | // Buffer text 272 | if (~contentType.indexOf('text/plain')) { 273 | res.setEncoding('utf8'); 274 | res.on('data', function(chunk){ buf += chunk; }); 275 | res.on('end', function(){ 276 | fn(res, buf.toString()); 277 | }); 278 | return; 279 | } 280 | 281 | // Ensure html 282 | if (!~contentType.indexOf('text/html')) { 283 | return fn(res); 284 | } 285 | 286 | // Buffer html 287 | res.setEncoding('utf8'); 288 | res.on('data', function(chunk){ buf += chunk; }); 289 | res.on('end', function(){ 290 | self.parse(buf); 291 | fn(res, function(selector){ 292 | return self.context.find(selector); 293 | }); 294 | }); 295 | } 296 | }); 297 | 298 | req.end(options.body); 299 | 300 | return this; 301 | }; 302 | 303 | /** 304 | * GET `path` and callback `fn(res, jQuery)`. 305 | * 306 | * @param {String} path 307 | * @param {Object|Function} options or fn 308 | * @param {Function} fn 309 | * @return {Browser} for chaining 310 | * @api public 311 | */ 312 | 313 | Browser.prototype.get = 314 | Browser.prototype.visit = 315 | Browser.prototype.open = function(path, options, fn, saveHistory){ 316 | if ('function' == typeof options) { 317 | saveHistory = fn; 318 | fn = options; 319 | options = {}; 320 | } 321 | return this.request('GET', path, options, fn, saveHistory); 322 | }; 323 | 324 | /** 325 | * HEAD `path` and callback `fn(res)`. 326 | * 327 | * @param {String} path 328 | * @param {Object|Function} options or fn 329 | * @param {Function} fn 330 | * @return {Browser} for chaining 331 | * @api public 332 | */ 333 | 334 | Browser.prototype.head = function(path, options, fn, saveHistory){ 335 | if ('function' == typeof options) { 336 | saveHistory = fn; 337 | fn = options; 338 | options = {}; 339 | } 340 | return this.request('HEAD', path, options, fn, saveHistory); 341 | }; 342 | 343 | /** 344 | * POST `path` and callback `fn(res, jQuery)`. 345 | * 346 | * @param {String} path 347 | * @param {Object|Function} options or fn 348 | * @param {Function} fn 349 | * @return {Browser} for chaining 350 | * @api public 351 | */ 352 | 353 | Browser.prototype.post = function(path, options, fn, saveHistory){ 354 | if ('function' == typeof options) { 355 | saveHistory = fn; 356 | fn = options; 357 | options = {}; 358 | } 359 | return this.request('POST', path, options, fn, saveHistory); 360 | }; 361 | 362 | /** 363 | * PUT `path` and callback `fn(res, jQuery)`. 364 | * 365 | * @param {String} path 366 | * @param {Object|Function} options or fn 367 | * @param {Function} fn 368 | * @return {Browser} for chaining 369 | * @api public 370 | */ 371 | 372 | Browser.prototype.put = function(path, options, fn, saveHistory){ 373 | if ('function' == typeof options) { 374 | saveHistory = fn; 375 | fn = options; 376 | options = {}; 377 | } 378 | return this.request('put', path, options, fn, saveHistory); 379 | }; 380 | 381 | /** 382 | * DELETE `path` and callback `fn(res, jQuery)`. 383 | * 384 | * @param {String} path 385 | * @param {Object|Function} options or fn 386 | * @param {Function} fn 387 | * @return {Browser} for chaining 388 | * @api public 389 | */ 390 | 391 | Browser.prototype.delete = function(path, options, fn, saveHistory){ 392 | if ('function' == typeof options) { 393 | saveHistory = fn; 394 | fn = options; 395 | options = {}; 396 | } 397 | return this.request('delete', path, options, fn, saveHistory); 398 | }; 399 | 400 | /** 401 | * GET the last page visited, or the nth previous page. 402 | * 403 | * @param {Number} n 404 | * @param {Function} fn 405 | * @return {Browser} for chaining 406 | * @api public 407 | */ 408 | 409 | Browser.prototype.back = function(n, fn){ 410 | if ('function' == typeof n) fn = n, n = 1; 411 | while (n--) this.history.pop(); 412 | return this.get(this.path, fn, false); 413 | }; 414 | 415 | /** 416 | * Locate elements via the given `selector` and `locator` supporting: 417 | * 418 | * - element text 419 | * - element name attribute 420 | * - css selector 421 | * 422 | * @param {String} selector 423 | * @param {String} locator 424 | * @return {jQuery} 425 | * @api private 426 | */ 427 | 428 | Browser.prototype.locate = function(selector, locator){ 429 | var self = this 430 | , $ = this.jQuery; 431 | var elems = this.context.find(selector).filter(function(){ 432 | var elem = $(this); 433 | return locator == elem.text() 434 | || locator == elem.attr('id') 435 | || locator == elem.attr('value') 436 | || locator == elem.attr('name') 437 | || elem.is(locator); 438 | }); 439 | if (elems && !elems.length) throw new Error('failed to locate "' + locator + '" in context of selector "' + selector + '"'); 440 | return elems; 441 | }; 442 | 443 | /** 444 | * Return the current path. 445 | * 446 | * @return {String} 447 | * @api public 448 | */ 449 | 450 | Browser.prototype.__defineGetter__('path', function(){ 451 | return this.history[this.history.length - 1]; 452 | }); 453 | 454 | /** 455 | * Click the given `locator` and callback `fn(res)`. 456 | * 457 | * @param {String} locator 458 | * @param {Function} fn 459 | * @return {Browser} for chaining 460 | * @api public 461 | */ 462 | 463 | Browser.prototype.click = function(locator, fn){ 464 | return this.jQuery(this.locate(':submit, :button, a', locator)).click(fn, locator); 465 | }; 466 | 467 | /** 468 | * Assign `val` to the given `locator`. 469 | * 470 | * @param {String} locator 471 | * @param {String} val 472 | * @return {Browser} for chaining 473 | * @api public 474 | */ 475 | 476 | Browser.prototype.type = function(locator, val){ 477 | this.jQuery(this.locate('input, textarea', locator)).val(val); 478 | return this; 479 | }; 480 | 481 | /** 482 | * Uncheck the checkbox with the given `locator`. 483 | * 484 | * @param {String} locator 485 | * @return {Assertion} for chaining 486 | * @api public 487 | */ 488 | 489 | Browser.prototype.uncheck = function(locator){ 490 | this.locate(':checkbox', locator)[0].removeAttribute('checked'); 491 | return this; 492 | }; 493 | 494 | /** 495 | * Check the checkbox with the given `locator`. 496 | * 497 | * @param {String} locator 498 | * @return {Assertion} for chaining 499 | * @api public 500 | */ 501 | 502 | Browser.prototype.check = function(locator){ 503 | this.locate(':checkbox', locator)[0].setAttribute('checked', 'checked'); 504 | return this; 505 | }; 506 | 507 | /** 508 | * Select `options` at `locator`. 509 | * 510 | * @param {String} locator 511 | * @param {String|Array} select 512 | * @return {Assertion} for chaining 513 | * @api public 514 | */ 515 | 516 | Browser.prototype.select = function(locator, options){ 517 | this.jQuery(this.locate('select', locator)).select(options); 518 | return this; 519 | }; 520 | 521 | /** 522 | * Submit form at the optional `locator` and callback `fn(res)`. 523 | * 524 | * @param {String|Function} locator 525 | * @param {Function} fn 526 | * @return {Browser} for chaining 527 | * @api public 528 | */ 529 | 530 | Browser.prototype.submit = function(locator, fn){ 531 | if ('function' == typeof locator) { 532 | fn = locator; 533 | locator = '*'; 534 | } 535 | return this.jQuery(this.locate('form', locator)).submit(fn, locator); 536 | }; 537 | 538 | /** 539 | * Fill the given form `fields` and optional `locator`. 540 | * 541 | * @param {String} locator 542 | * @param {Object} fields 543 | * @return {Assertion} for chaining 544 | * @api public 545 | */ 546 | 547 | Browser.prototype.fill = function(locator, fields){ 548 | if ('object' == typeof locator) { 549 | fields = locator; 550 | locator = '*'; 551 | } 552 | this.jQuery(this.locate('form', locator)).fill(fields); 553 | return this; 554 | }; 555 | 556 | /** 557 | * Return text at the given `locator`. 558 | * 559 | * @param {String} locator 560 | * @return {String} 561 | * @api public 562 | */ 563 | 564 | Browser.prototype.text = function(locator){ 565 | return this.jQuery(this.locate('*', locator)).text(); 566 | }; 567 | 568 | /** 569 | * Ensure `html` / `body` tags exist. 570 | * 571 | * @return {String} 572 | * @api public 573 | */ 574 | 575 | function wrap(html) { 576 | // body 577 | if (!~html.indexOf(''; 579 | } 580 | 581 | // html 582 | if (!~html.indexOf(''; 584 | } 585 | 586 | return html; 587 | } 588 | -------------------------------------------------------------------------------- /test/browser.navigation.test.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Module dependencies. 4 | */ 5 | 6 | var tobi = require('../') 7 | , express = require('express') 8 | , Browser = tobi.Browser 9 | , should = require('should'); 10 | 11 | // Test app 12 | 13 | var app = express.createServer(); 14 | 15 | app.use(express.bodyParser()); 16 | 17 | app.get('/', function(req, res){ 18 | res.send('

Hello World

'); 19 | }); 20 | 21 | app.post('/', function(req, res){ 22 | res.send('

POST

'); 23 | }); 24 | 25 | app.put('/', function(req, res){ 26 | res.send('

PUT

'); 27 | }); 28 | 29 | app.del('/', function(req, res){ 30 | res.send('

DELETE

'); 31 | }); 32 | 33 | app.get('/404', function(req, res){ 34 | res.send(404); 35 | }); 36 | 37 | app.get('/500', function(req, res){ 38 | res.send('

OH NO!

', 500); 39 | }); 40 | 41 | app.get('/redirect', function(req, res){ 42 | res.redirect('/one'); 43 | }); 44 | 45 | app.get('/xml', function(req, res){ 46 | res.contentType('.xml'); 47 | res.send('tj'); 48 | }); 49 | 50 | app.get('/json', function(req, res){ 51 | res.send({ user: 'tj' }); 52 | }); 53 | 54 | app.get('/users.json', function(req, res){ 55 | res.send([ 56 | { name: 'tobi' } 57 | , { name: 'loki' } 58 | , { name: 'jane' } 59 | ]); 60 | }); 61 | 62 | app.get('/users', function(req, res){ 63 | res.send('Users JSON'); 64 | }); 65 | 66 | app.get('/invalid-json', function(req, res){ 67 | res.send('{"broken":', { 'Content-Type': 'application/json' }); 68 | }); 69 | 70 | app.get('/user/:id', function(req, res){ 71 | res.send('

Tobi

the ferret

'); 72 | }); 73 | 74 | app.get('/one', function(req, res){ 75 | res.send( 76 | 'Page Two' 77 | + 'Page Three'); 78 | }); 79 | 80 | app.get('/two', function(req, res){ 81 | res.send('Page Three'); 82 | }); 83 | 84 | app.get('/three', function(req, res){ 85 | res.send('

Wahoo! Page Three

'); 86 | }); 87 | 88 | app.get('/search', function(req, res){ 89 | res.send('
' 90 | + '' 91 | + '
'); 92 | }); 93 | 94 | app.get('/search/results', function(req, res){ 95 | res.send(req.query); 96 | }); 97 | 98 | app.get('/form', function(req, res){ 99 | res.send('
' 100 | + '' 101 | + '' 102 | + '' 103 | + '' 104 | + '' 105 | + '
' 106 | + ' ' 111 | + ' ' 112 | + ' ' 113 | + ' ' 114 | + '
' 115 | + '
'); 116 | }); 117 | 118 | app.get('/form-nested', function(req, res){ 119 | res.send('
' 120 | + '' 121 | + '' 122 | + '' 123 | + '' 124 | + '
' 125 | + ' ' 130 | + ' ' 131 | + ' ' 132 | + ' ' 133 | + ' ' 134 | + '
' 135 | + '
'); 136 | }); 137 | 138 | app.post('/form', function(req, res){ 139 | res.send({ headers: req.headers, body: req.body }); 140 | }); 141 | 142 | // Deferred app 143 | 144 | var appDeferred = express.createServer() 145 | , oldListen = appDeferred.listen; 146 | 147 | appDeferred.listen = function(){ 148 | var args = arguments; 149 | setTimeout(function(){ 150 | oldListen.apply(appDeferred, args); 151 | }, 100); 152 | }; 153 | 154 | appDeferred.get('/', function(req, res){ 155 | res.send(200); 156 | }); 157 | 158 | module.exports = { 159 | 'test .request() invalid response': function(done){ 160 | var browser = tobi.createBrowser(app); 161 | browser.on('error', function(err){ 162 | err.message.should.equal('Unexpected end of input'); 163 | done(); 164 | }); 165 | browser.request('GET', '/invalid-json', {}, function(res){ 166 | // Nothing 167 | }); 168 | }, 169 | 170 | 'test .request() non-html': function(done){ 171 | var browser = tobi.createBrowser(app); 172 | browser.request('GET', '/xml', {}, function(res){ 173 | res.should.have.header('Content-Type', 'application/xml'); 174 | res.should.not.have.property('body'); 175 | done(); 176 | }); 177 | }, 178 | 179 | 'test .createBrowser(port, host)': function(done){ 180 | var server = express.createServer(); 181 | server.get('/', function(req, res){ res.send({ hello: 'tobi' }); }); 182 | server.listen(9999); 183 | server.on('listening', function(){ 184 | var browser = tobi.createBrowser(9999, '127.0.0.1'); 185 | browser.request('GET', '/', {}, function(res, obj){ 186 | res.should.have.status(200); 187 | obj.should.eql({ hello: 'tobi' }); 188 | server.close(); 189 | done(); 190 | }); 191 | }); 192 | }, 193 | 194 | 'test .request() json': function(done){ 195 | var browser = tobi.createBrowser(app); 196 | browser.request('GET', '/json', {}, function(res, obj){ 197 | res.body.should.eql({ user: 'tj' }); 198 | obj.should.eql({ user: 'tj' }); 199 | 200 | browser.get('/json', function(res, obj){ 201 | obj.should.eql({ user: 'tj' }); 202 | done(); 203 | }); 204 | }); 205 | }, 206 | 207 | 'test .request() 404': function(done){ 208 | var browser = tobi.createBrowser(app); 209 | browser.request('GET', '/404', {}, function(res){ 210 | res.should.have.status(404); 211 | done(); 212 | }); 213 | }, 214 | 215 | 'test .request() error': function(done){ 216 | var browser = tobi.createBrowser(app); 217 | browser.request('GET', '/500', {}, function(res, $){ 218 | res.should.have.status(500); 219 | $('p').text().should.equal('OH NO!'); 220 | done(); 221 | }); 222 | }, 223 | 224 | 'test .request(method, path)': function(done){ 225 | var browser = tobi.createBrowser(app); 226 | browser.request('GET', '/', {}, function(res, $){ 227 | res.should.have.status(200); 228 | browser.should.have.property('path', '/'); 229 | browser.history.should.eql(['/']); 230 | browser.request('GET', '/user/0', {}, function(){ 231 | browser.should.have.property('path', '/user/0'); 232 | browser.history.should.eql(['/', '/user/0']); 233 | browser.should.have.property('source', '

Tobi

the ferret

'); 234 | browser.jQuery('p').text().should.equal('the ferret'); 235 | done(); 236 | }); 237 | }); 238 | }, 239 | 240 | 'test .request(method, url)': function(done){ 241 | var browser = tobi.createBrowser(app); 242 | browser.request('GET', 'http://127.0.0.1:' + app.__port, {}, function(res, $){ 243 | res.should.have.status(200); 244 | browser.should.have.property('path', '/'); 245 | browser.history.should.eql(['/']); 246 | browser.request('GET', 'http://127.0.0.1:' + app.__port + '/user/0', {}, function(){ 247 | browser.should.have.property('path', '/user/0'); 248 | browser.history.should.eql(['/', '/user/0']); 249 | browser.should.have.property('source', '

Tobi

the ferret

'); 250 | browser.jQuery('p').text().should.equal('the ferret'); 251 | done(); 252 | }); 253 | }); 254 | }, 255 | 256 | 'test .request(method, foreignUrl)': function(done){ 257 | var browser = tobi.createBrowser(app); 258 | browser.request('GET', 'http://www.google.com/', {}, function(res, $){ 259 | res.should.have.status(200); 260 | browser.should.not.have.property('path'); 261 | browser.history.should.be.empty; 262 | 263 | var googBrowser = Browser.browsers['www.google.com'] 264 | googBrowser.should.have.property('path', '/'); 265 | googBrowser.history.should.eql(['/']); 266 | // googBrowser.jQuery('img[alt="Google"]').length.should.equal(1); 267 | done(); 268 | }); 269 | }, 270 | 271 | 'test .request(method, foreignHttpsUrl)': function(done){ 272 | var browser = tobi.createBrowser(app); 273 | browser.request('GET', 'https://www.github.com/', {}, function(res, $){ 274 | res.should.have.status(200); 275 | browser.should.not.have.property('path'); 276 | browser.history.should.be.empty; 277 | 278 | var googBrowser = Browser.browsers['github.com'] 279 | googBrowser.should.have.property('path', '/'); 280 | googBrowser.history.should.eql(['/']); 281 | googBrowser.jQuery('img[alt="github"]').should.not.be.empty; 282 | done(); 283 | }); 284 | }, 285 | 286 | 'test .request() redirect': function(done){ 287 | var browser = tobi.createBrowser(app); 288 | browser.request('GET', '/redirect', {}, function(res, $){ 289 | res.should.have.status(200); 290 | browser.should.have.property('path', '/one'); 291 | browser.history.should.eql(['/redirect', '/one']); 292 | done(); 293 | }); 294 | }, 295 | 296 | 'test .request() redirect when followRedirects is false': function(done) { 297 | var browser = tobi.createBrowser(app); 298 | browser.followRedirects = false; 299 | browser.request('GET', '/redirect', {}, function(res, $){ 300 | res.should.have.status(302); 301 | browser.should.have.property('path', '/redirect'); 302 | browser.history.should.eql(['/redirect']); 303 | done(); 304 | }); 305 | }, 306 | 307 | 'test .request() redirect to a full uri with different hostname': function(done){ 308 | var browser = tobi.createBrowser(80, 'bit.ly') 309 | Browser.browsers.should.not.have.property('bit.ly'); 310 | Browser.browsers.should.not.have.property('node.js'); 311 | browser.request('GET', 'http://bit.ly/mQETJ8', {}, function (res, $) { 312 | res.should.have.status(200); 313 | Browser.browsers.should.have.property('bit.ly'); 314 | Browser.browsers.should.have.property('nodejs.org'); 315 | var nodeBrowser = Browser.browsers['nodejs.org']; 316 | nodeBrowser.jQuery('img[alt="node.js"]').length.should.equal(1); 317 | done(); 318 | }); 319 | }, 320 | 321 | 'test .request redirecting from a full non-https uri to a https uri': function(done){ 322 | var browser = tobi.createBrowser(80, 'bit.ly') 323 | browser.request('GET', 'http://bit.ly/jrs5ME', {}, function (res, $) { 324 | res.should.have.status(200); 325 | var githubBrowser = Browser.browsers['github.com']; 326 | githubBrowser.jQuery('#slider .breadcrumb a').should.have.text('tobi'); 327 | done(); 328 | }); 329 | }, 330 | 331 | // [!] if this test doesn't pass, an uncaught ECONNREFUSED will be shown 332 | 'test .request() on deferred listen()': function(done){ 333 | var browser = tobi.createBrowser(appDeferred) 334 | , total = 2; 335 | 336 | function next() { 337 | appDeferred.close(); 338 | done(); 339 | } 340 | 341 | browser.request('GET', '/', {}, function(res){ 342 | res.should.have.status(200); 343 | --total || next(); 344 | }); 345 | 346 | browser.request('GET', '/', {}, function(res){ 347 | res.should.have.status(200); 348 | --total || next(); 349 | }); 350 | }, 351 | 352 | 'test .back(fn)': function(done){ 353 | var browser = tobi.createBrowser(app); 354 | browser.request('GET', '/', {}, function(res, $){ 355 | res.should.have.status(200); 356 | browser.request('GET', '/user/0', {}, function(){ 357 | browser.back(function(){ 358 | browser.should.have.property('path', '/'); 359 | done(); 360 | }); 361 | }); 362 | }); 363 | }, 364 | 365 | 'test .head(path)': function(done){ 366 | var browser = tobi.createBrowser(app); 367 | browser.head('/form', function(res){ 368 | browser.source.should.be.empty; 369 | done(); 370 | }); 371 | }, 372 | 373 | 'test .post(path)': function(done){ 374 | var browser = tobi.createBrowser(app); 375 | browser.post('/', function(res, $){ 376 | res.should.have.status(200); 377 | $('p').should.have.text('POST'); 378 | browser.should.have.property('path', '/'); 379 | browser.history.should.eql(['/']); 380 | done(); 381 | }); 382 | }, 383 | 384 | 'test .post(path) with passed body': function(done) { 385 | var browser = tobi.createBrowser(app); 386 | browser.post('/form', {body: "foo=bar"}, function(res, $){ 387 | res.should.have.status(200); 388 | res.body.body.should.eql({foo:"bar"}); 389 | browser.should.have.property('path', '/form'); 390 | browser.history.should.eql(['/form']); 391 | done(); 392 | }); 393 | }, 394 | 395 | 'test .put(path)': function(done){ 396 | var browser = tobi.createBrowser(app); 397 | browser.put('/', function(res, $){ 398 | res.should.have.status(200); 399 | $('p').should.have.text('PUT'); 400 | browser.should.have.property('path', '/'); 401 | browser.history.should.eql(['/']); 402 | done(); 403 | }); 404 | }, 405 | 406 | 'test .delete(path)': function(done){ 407 | var browser = tobi.createBrowser(app); 408 | browser.delete('/', function(res, $){ 409 | res.should.have.status(200); 410 | $('p').should.have.text('DELETE'); 411 | browser.should.have.property('path', '/'); 412 | browser.history.should.eql(['/']); 413 | done(); 414 | }); 415 | }, 416 | 417 | 'test .get(path)': function(done){ 418 | var browser = tobi.createBrowser(app); 419 | browser.visit.should.equal(browser.get); 420 | browser.open.should.equal(browser.get); 421 | browser.get('/', function(){ 422 | browser.should.have.property('path', '/'); 423 | browser.history.should.eql(['/']); 424 | done(); 425 | }); 426 | }, 427 | 428 | 'test .locate(css)': function(){ 429 | var browser = tobi.createBrowser('
  • One
  • Two
'); 430 | browser.locate('*', 'ul > li').should.have.length(2); 431 | browser.locate('*', 'li').should.have.length(2); 432 | browser.locate('*', 'li:last-child').should.have.length(1); 433 | browser.locate('*', 'li:contains(One)').should.have.length(1); 434 | browser.locate('ul', ':contains(One)').should.have.length(1); 435 | }, 436 | 437 | 'test .locate(name)': function(){ 438 | var browser = tobi.createBrowser('

'); 439 | browser.locate('*', 'username').should.have.length(1); 440 | browser.locate('*', 'signature').should.have.length(1); 441 | browser.locate('form > p > input', 'username').should.have.length(1); 442 | 443 | var err; 444 | try { 445 | browser.locate('form > p', 'signature').should.have.length(1); 446 | } catch (e) { 447 | err = e; 448 | } 449 | err.should.have.property('message', 'failed to locate "signature" in context of selector "form > p"'); 450 | }, 451 | 452 | 'test .locate(value)': function(){ 453 | var browser = tobi.createBrowser( 454 | '

' 455 | + '

'); 456 | browser.locate('*', 'Save').should.have.length(1); 457 | browser.locate('*', 'Delete').should.have.length(1); 458 | browser.locate('form input', 'Delete').should.have.length(1); 459 | }, 460 | 461 | 'test .locate(text)': function(){ 462 | var browser = tobi.createBrowser( 463 | '

Foo

' 464 | + '

Foo

' 465 | + '

Bar

' 466 | + '

Baz

'); 467 | browser.locate('*', 'Foo').should.have.length(2); 468 | browser.locate('*', 'Bar').should.have.length(1); 469 | browser.locate('*', 'Baz').should.have.length(1); 470 | browser.locate('div p', 'Foo').should.have.length(2); 471 | browser.locate('div p', 'Baz').should.have.length(1); 472 | }, 473 | 474 | 'test .click(text, fn)': function(done){ 475 | var browser = tobi.createBrowser(app); 476 | browser.get('/one', function(){ 477 | browser.click('Page Two', function(){ 478 | browser.should.have.property('path', '/two'); 479 | browser.click('Page Three', function(){ 480 | browser.should.have.property('path', '/three'); 481 | browser.source.should.equal('

Wahoo! Page Three

'); 482 | done(); 483 | }) 484 | }); 485 | }); 486 | }, 487 | 488 | 'test .click(id, fn)': function(done){ 489 | var browser = tobi.createBrowser(app); 490 | browser.get('/one', function(){ 491 | browser.click('page-two', function(){ 492 | browser.should.have.property('path', '/two'); 493 | browser.click('page-three', function(){ 494 | browser.should.have.property('path', '/three'); 495 | browser.source.should.equal('

Wahoo! Page Three

'); 496 | browser.back(function(){ 497 | browser.should.have.property('path', '/two'); 498 | browser.back(function(){ 499 | browser.should.have.property('path', '/one'); 500 | browser.click('page-three', function(){ 501 | browser.should.have.property('path', '/three'); 502 | done(); 503 | }); 504 | }); 505 | }); 506 | }) 507 | }); 508 | }); 509 | }, 510 | 511 | 'test .click(css, fn)': function(done){ 512 | var browser = tobi.createBrowser(app); 513 | browser.get('/one', function(){ 514 | browser.click('a[href="/two"]', function(){ 515 | browser.should.have.property('path', '/two'); 516 | browser.click('a[href="/three"]', function(){ 517 | browser.should.have.property('path', '/three'); 518 | browser.source.should.equal('

Wahoo! Page Three

'); 519 | browser.back(2, function(){ 520 | browser.should.have.property('path', '/one'); 521 | done(); 522 | }); 523 | }) 524 | }); 525 | }); 526 | }, 527 | 528 | 'test .uncheck(name)': function(done){ 529 | var browser = tobi.createBrowser(app); 530 | browser.get('/form', function(res, $){ 531 | res.should.have.status(200); 532 | $('[name="user[subscribe]"]').should.be.checked; 533 | browser.uncheck('user[subscribe]'); 534 | $('[name="user[subscribe]"]').should.not.be.checked; 535 | done(); 536 | }); 537 | }, 538 | 539 | 'test .check(name)': function(done){ 540 | var browser = tobi.createBrowser(app); 541 | browser.get('/form', function(res, $){ 542 | res.should.have.status(200); 543 | $('[name="user[agreement]"]').should.not.be.checked; 544 | browser.check('user[agreement]'); 545 | $('[name="user[agreement]"]').should.be.checked; 546 | done(); 547 | }); 548 | }, 549 | 550 | 'test .check(css)': function(done){ 551 | var browser = tobi.createBrowser(app); 552 | browser.get('/form', function(res, $){ 553 | res.should.have.status(200); 554 | $('[name="user[agreement]"]').should.not.be.checked; 555 | browser.check('[name="user[agreement]"]'); 556 | $('[name="user[agreement]"]').should.be.checked; 557 | done(); 558 | }); 559 | }, 560 | 561 | 'test .check(id)': function(done){ 562 | var browser = tobi.createBrowser(app); 563 | browser.get('/form', function(res, $){ 564 | res.should.have.status(200); 565 | $('[name="user[agreement]"]').should.not.be.checked; 566 | browser.check('user-agreement'); 567 | $('[name="user[agreement]"]').should.be.checked; 568 | done(); 569 | }); 570 | }, 571 | 572 | 'test jQuery#click(fn)': function(done){ 573 | var browser = tobi.createBrowser(app); 574 | browser.get('/one', function(res, $){ 575 | res.should.have.status(200); 576 | $('a:last-child').click(function(){ 577 | browser.should.have.property('path', '/three'); 578 | browser.back(function(){ 579 | $('a').click(function(){ 580 | browser.should.have.property('path', '/two'); 581 | done(); 582 | }); 583 | }); 584 | }); 585 | }); 586 | }, 587 | 588 | 'test jQuery#click(fn) form submit button': function(done){ 589 | var browser = tobi.createBrowser(app); 590 | browser.get('/form', function(res, $){ 591 | res.should.have.status(200); 592 | $('[name="user[name]"]').val('tjholowaychuk'); 593 | $('[name="user[email]"]').val('tj@vision-media.ca'); 594 | $('[type=submit]').click(function(res){ 595 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 596 | res.body.body.should.eql({ 597 | user: { 598 | name: 'tjholowaychuk' 599 | , subscribe: 'yes' 600 | , signature: '' 601 | , forum_digest: 'none' 602 | }, 603 | update: 'Update' 604 | }); 605 | done(); 606 | }); 607 | }); 608 | }, 609 | 610 | 'test .type()': function(done){ 611 | var browser = tobi.createBrowser(app); 612 | browser.get('/form', function(res, $){ 613 | res.should.have.status(200); 614 | browser.type('user[name]', 'tjholowaychuk'); 615 | browser.type('user[email]', 'tj@vision-media.ca'); 616 | browser.click('Update', function(res){ 617 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 618 | res.body.body.should.eql({ 619 | user: { 620 | name: 'tjholowaychuk' 621 | , subscribe: 'yes' 622 | , signature: '' 623 | , forum_digest: 'none' 624 | }, 625 | update: 'Update' 626 | }); 627 | done(); 628 | }); 629 | }); 630 | }, 631 | 632 | 'test .type() chaining': function(done){ 633 | var browser = tobi.createBrowser(app); 634 | browser.get('/form', function(res, $){ 635 | res.should.have.status(200); 636 | browser 637 | .type('user[name]', 'tjholowaychuk') 638 | .type('user[email]', 'tj@vision-media.ca') 639 | .check('user[agreement]') 640 | .click('Update', function(res){ 641 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 642 | res.body.body.should.eql({ 643 | user: { 644 | name: 'tjholowaychuk' 645 | , agreement: 'yes' 646 | , subscribe: 'yes' 647 | , signature: '' 648 | , forum_digest: 'none' 649 | }, 650 | update: 'Update' 651 | }); 652 | done(); 653 | }); 654 | }); 655 | }, 656 | 657 | 'test .fill(obj) names': function(done){ 658 | var browser = tobi.createBrowser(app); 659 | browser.get('/form', function(res, $){ 660 | res.should.have.status(200); 661 | browser.fill({ 662 | 'user[name]': 'tjholowaychuk' 663 | , 'user[email]': 'tj@vision-media.ca' 664 | , 'user[agreement]': true 665 | , 'user[subscribe]': false 666 | , 'user[display_signature]': 'No' 667 | , 'user[forum_digest]': 'daily' 668 | , 'signature': 'TJ Holowaychuk' 669 | }) 670 | .click('Update', function(res){ 671 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 672 | res.body.body.should.eql({ 673 | user: { 674 | name: 'tjholowaychuk' 675 | , agreement: 'yes' 676 | , signature: 'TJ Holowaychuk' 677 | , display_signature: 'No' 678 | , forum_digest: 'daily' 679 | }, 680 | update: 'Update' 681 | }); 682 | done(); 683 | }); 684 | }); 685 | }, 686 | 687 | 'test .fill(obj) css': function(done){ 688 | var browser = tobi.createBrowser(app); 689 | browser.get('/form', function(res, $){ 690 | res.should.have.status(200); 691 | browser.fill({ 692 | 'form > #user-name': 'tjholowaychuk' 693 | , 'form > #user-email': 'tj@vision-media.ca' 694 | , ':checkbox': true 695 | , 'user[display_signature]': 'No' 696 | , '[name="user[forum_digest]"]': 'daily' 697 | , '#signature': 'TJ Holowaychuk' 698 | }) 699 | .click(':submit', function(res){ 700 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 701 | res.body.body.should.eql({ 702 | user: { 703 | name: 'tjholowaychuk' 704 | , agreement: 'yes' 705 | , subscribe: 'yes' 706 | , signature: 'TJ Holowaychuk' 707 | , display_signature: 'No' 708 | , forum_digest: 'daily' 709 | }, 710 | update: 'Update' 711 | }); 712 | done(); 713 | }); 714 | }); 715 | }, 716 | 717 | 'test jQuery#submit() POST': function(done){ 718 | var browser = tobi.createBrowser(app); 719 | browser.get('/form', function(res, $){ 720 | res.should.have.status(200); 721 | $('[name="user[name]"]').val('tjholowaychuk'); 722 | $('#signature').val('Wahoo'); 723 | $('form').submit(function(res){ 724 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 725 | res.body.body.should.eql({ 726 | user: { 727 | name: 'tjholowaychuk' 728 | , subscribe: 'yes' 729 | , signature: 'Wahoo' 730 | , forum_digest: 'none' 731 | }, 732 | update: 'Update' 733 | }); 734 | done(); 735 | }); 736 | }); 737 | }, 738 | 739 | 'test jQuery#submit() POST with a submit input not being a direct descendent': 740 | function(done){ 741 | var browser = tobi.createBrowser(app); 742 | browser.get('/form-nested', function(res, $){ 743 | res.should.have.status(200); 744 | $('[name="user[name]"]').val('tjholowaychuk'); 745 | $('#signature').val('Wahoo'); 746 | $('form').submit(function(res){ 747 | res.body.headers.should.have.property('content-type', 748 | 'application/x-www-form-urlencoded'); 749 | res.body.body.should.eql({ 750 | user: { 751 | name: 'tjholowaychuk' 752 | , subscribe: 'yes' 753 | , signature: 'Wahoo' 754 | , forum_digest: 'none' 755 | } 756 | }); 757 | done(); 758 | }); 759 | }); 760 | }, 761 | 762 | 'test .submit() POST': function(done){ 763 | var browser = tobi.createBrowser(app); 764 | browser.get('/form', function(res, $){ 765 | res.should.have.status(200); 766 | $('[name="user[name]"]').val('tjholowaychuk'); 767 | $('#signature').val('Wahoo'); 768 | browser.submit('user', function(res){ 769 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 770 | res.body.body.should.eql({ 771 | user: { 772 | name: 'tjholowaychuk' 773 | , subscribe: 'yes' 774 | , signature: 'Wahoo' 775 | , forum_digest: 'none' 776 | }, 777 | update: 'Update' 778 | }); 779 | done(); 780 | }); 781 | }); 782 | }, 783 | 784 | 'test .submit() GET': function(done){ 785 | var browser = tobi.createBrowser(app); 786 | browser.get('/search', function(res, $){ 787 | res.should.have.status(200); 788 | browser 789 | .fill({ query: 'ferret hats' }) 790 | .submit(function(res){ 791 | res.body.should.eql({ query: 'ferret hats' }); 792 | done(); 793 | }); 794 | }); 795 | }, 796 | 797 | 'test select single option': function(done){ 798 | var browser = tobi.createBrowser(app); 799 | browser.get('/form', function(res, $){ 800 | res.should.have.status(200); 801 | $('select option[value=daily]').attr('selected', 'selected'); 802 | browser.submit('user', function(res){ 803 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 804 | res.body.body.should.eql({ 805 | user: { 806 | name: '' 807 | , signature: '' 808 | , subscribe: 'yes' 809 | , forum_digest: 'daily' 810 | }, 811 | update: 'Update' 812 | }); 813 | done(); 814 | }); 815 | }); 816 | }, 817 | 818 | 'test select multiple options': function(done){ 819 | var browser = tobi.createBrowser(app); 820 | browser.get('/form', function(res, $){ 821 | res.should.have.status(200); 822 | $('select option[value=daily]').attr('selected', 'selected'); 823 | $('select option[value=weekly]').attr('selected', 'selected'); 824 | browser.submit('user', function(res){ 825 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 826 | res.body.body.should.eql({ 827 | user: { 828 | name: '' 829 | , signature: '' 830 | , subscribe: 'yes' 831 | , forum_digest: ['daily', 'weekly'] 832 | }, 833 | update: 'Update' 834 | }); 835 | done(); 836 | }); 837 | }); 838 | }, 839 | 840 | 'test .select() single option by value': function(done){ 841 | var browser = tobi.createBrowser(app); 842 | browser.get('/form', function(res, $){ 843 | res.should.have.status(200); 844 | $('select option[value=daily]').attr('selected', 'selected'); 845 | browser 846 | .select('user[forum_digest]', 'daily') 847 | .submit('user', function(res){ 848 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 849 | res.body.body.should.eql({ 850 | user: { 851 | name: '' 852 | , signature: '' 853 | , subscribe: 'yes' 854 | , forum_digest: 'daily' 855 | }, 856 | update: 'Update' 857 | }); 858 | done(); 859 | }); 860 | }); 861 | }, 862 | 863 | 'test .select() multiple options by value': function(done){ 864 | var browser = tobi.createBrowser(app); 865 | browser.get('/form', function(res, $){ 866 | res.should.have.status(200); 867 | $('select option[value=daily]').attr('selected', 'selected'); 868 | browser 869 | .select('user[forum_digest]', ['daily', 'weekly']) 870 | .submit('user', function(res){ 871 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 872 | res.body.body.should.eql({ 873 | user: { 874 | name: '' 875 | , signature: '' 876 | , subscribe: 'yes' 877 | , forum_digest: ['daily', 'weekly'] 878 | }, 879 | update: 'Update' 880 | }); 881 | done(); 882 | }); 883 | }); 884 | }, 885 | 886 | 'test .select() multiple options by text': function(done){ 887 | var browser = tobi.createBrowser(app); 888 | browser.get('/form', function(res, $){ 889 | res.should.have.status(200); 890 | $('select option[value=daily]').attr('selected', 'selected'); 891 | browser 892 | .select('user[forum_digest]', ['Once per day', 'Once per week']) 893 | .submit('user', function(res){ 894 | res.body.headers.should.have.property('content-type', 'application/x-www-form-urlencoded'); 895 | res.body.body.should.eql({ 896 | user: { 897 | name: '' 898 | , signature: '' 899 | , subscribe: 'yes' 900 | , forum_digest: ['daily', 'weekly'] 901 | }, 902 | update: 'Update' 903 | }); 904 | done(); 905 | }); 906 | }); 907 | }, 908 | 909 | 'test .select() default select option value': function(done){ 910 | var browser = tobi.createBrowser(app); 911 | browser.get('/form', function(res, $){ 912 | browser 913 | .submit('user', function(res){ 914 | res.body.body.should.eql({ 915 | user: { 916 | name: '' 917 | , signature: '' 918 | , subscribe: 'yes' 919 | , forum_digest: 'none' 920 | }, 921 | update: 'Update' 922 | }); 923 | done(); 924 | }); 925 | }); 926 | }, 927 | 928 | 'test .text()': function(done){ 929 | var browser = tobi.createBrowser(app); 930 | browser.get('/form', function(res, $){ 931 | var txt = browser.text('user[forum_digest]'); 932 | txt.replace(/\s+/g, ' ').trim().should.equal('None Once per day Once per week'); 933 | var txt = browser.text('daily'); 934 | txt.should.equal('Once per day'); 935 | done(); 936 | }); 937 | }, 938 | 939 | 'test setting user-agent': function(done){ 940 | var browser = tobi.createBrowser(80, 'whatsmyuseragent.com'); 941 | browser.get('http://whatsmyuseragent.com', function(res, $){ 942 | res.should.have.status(200); 943 | $('h4:first').should.have.text(''); 944 | browser.userAgent = 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.100 Safari/534.30'; 945 | browser.get('/', function(res,$){ 946 | res.should.have.status(200); 947 | $('h4:first').should.have.text('Mozilla/5.0 (X11; Linux i686) AppleWebKit/534.30 (KHTML, like Gecko) Chrome/12.0.742.100 Safari/534.30'); 948 | done(); 949 | }); 950 | }); 951 | }, 952 | 953 | 'test JSON link': function(done){ 954 | var browser = tobi.createBrowser(app); 955 | browser.get('/users', function(res, $){ 956 | $('a').click(function(res, obj){ 957 | obj.should.eql([ 958 | { name: 'tobi' } 959 | , { name: 'loki' } 960 | , { name: 'jane' } 961 | ]); 962 | done(); 963 | }); 964 | }); 965 | }, 966 | 967 | after: function(){ 968 | app.close(); 969 | } 970 | }; 971 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | Fork me on GitHub 2 | 3 | Tobi 4 | 5 | 99 | 109 | 110 | 111 | 112 | 116 | 122 | 123 | 124 | 128 | 131 | 132 | 133 | 140 | 143 | 144 | 145 | 152 | 172 | 173 | 174 | 182 | 196 | 197 | 198 | 206 | 224 | 225 | 226 | 233 | 255 | 256 | 257 | 264 | 276 | 277 | 278 | 285 | 292 | 293 | 294 | 301 | 315 | 316 | 317 | 324 | 327 | 328 | 329 | 336 | 339 | 340 | 341 | 348 | 351 | 352 | 353 | 360 | 363 | 364 | 365 | 372 | 375 | 376 | 377 | 384 | 387 | 388 | 389 | 396 | 399 | 400 | 401 | 408 | 411 | 412 | 413 | 420 | 423 | 424 | 425 | 432 | 435 | 436 | 437 | 444 | 447 | 448 | 449 | 456 | 469 | 470 | 471 | 478 | 491 | 492 | 493 | 500 | 503 | 504 | 505 | 512 | 515 | 516 | 517 | 524 | 538 | 539 | 540 | 547 | 563 | 564 | 568 | 576 | 577 | 578 | 582 | 585 | 586 | 587 | 594 | 607 | 608 | 609 | 613 | 616 | 617 | 618 | 627 | 637 | 638 | 639 | 646 | 656 | 657 | 658 | 665 | 761 | 762 | 763 | 770 | 782 | 783 | 784 | 791 | 801 | 802 | 803 | 810 | 817 | 818 | 819 | 828 | 844 | 845 | 846 | 853 | 858 | 859 | 860 | 867 | 872 | 873 | 874 | 881 | 887 | 888 | 889 | 896 | 902 | 903 | 904 | 911 | 917 | 918 | 919 | 926 | 932 | 933 | 934 | 941 | 950 | 951 | 952 | 959 | 969 | 970 | 971 | 978 | 993 | 994 | 998 | 1001 | 1002 | 1003 | 1010 | 1038 | 1039 | 1040 | 1047 | 1053 | 1054 | 1058 | 1061 | 1062 | 1063 | 1068 | 1073 | 1074 | 1075 | 1082 | 1098 | 1099 | 1103 | 1108 | 1109 | 1110 | 1115 | 1136 | 1137 | 1138 | 1142 | 1147 | 1148 | 1152 | 1156 | 1157 | 1158 | 1165 | 1168 | 1169 | 1170 | 1177 | 1196 | 1197 | 1198 | 1208 | 1247 | 1248 | 1249 | 1256 | 1267 | 1268 | 1269 | 1276 | 1290 | 1291 | 1295 | 1298 | 1299 | 1300 | 1304 | 1307 | 1308 | 1309 | 1313 | 1316 | 1317 | 1318 | 1322 | 1325 | 1326 | 1327 | 1331 | 1336 | 1337 | 1338 | 1342 | 1350 | 1351 |

Tobi

Expressive server-side functional testing with jQuery and jsdom.

should

lib/assertions/should.js
113 |

Module dependencies. 114 |

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

Number strings. 126 |

127 |
129 |
var nums = { 0: 'none', 1: 'one', 2: 'two', 3: 'three' };
130 |
134 |

Return string representation for n.

135 | 136 |

137 | 138 |
  • param: Number n

  • return: String

  • api: private

139 |
141 |
function n(n) { return nums[n] || n; }
142 |
146 |

Assert text as str or a RegExp.

147 | 148 |

149 | 150 |
  • param: String | RegExp str

  • return: Assertion for chaining

  • api: public

151 |
153 |
Assertion.prototype.text = function(str){
 154 |   var elem = this.obj
 155 |     , text = elem.text();
 156 | 
 157 |   if (str instanceof RegExp) {
 158 |     this.assert(
 159 |         str.test(text)
 160 |       , 'expected ' + j(elem)+ ' to have text matching ' + i(str)
 161 |       , 'expected ' + j(elem) + ' text ' + i(text) + ' to not match ' + i(str));
 162 |   } else {
 163 |     this.assert(
 164 |         str == text
 165 |       , 'expected ' + j(elem) + ' to have text ' + i(str) + ', but has ' + i(text)
 166 |       , 'expected ' + j(elem) + ' to not have text ' + i(str));
 167 |   }
 168 | 
 169 |   return this;
 170 | };
171 |
175 |

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

177 | 178 |

179 | 180 |
  • param: String selector

  • return: Assertion for chaining

  • api: public

181 |
183 |
Assertion.prototype.many = function(selector){
 184 |   var elem = this.obj
 185 |     , elems = elem.find(selector)
 186 |     , len = elems.length;
 187 | 
 188 |   this.assert(
 189 |       this.negate ? len &gt; 1 : len
 190 |     , 'expected ' + j(elem) + ' to have many ' + i(selector) + ' tags, but has ' + n(len)
 191 |     , 'expected ' + j(elem) + ' to not have many ' + i(selector) + ' tags, but has ' + n(len));
 192 | 
 193 |   return this;
 194 | };
195 |
199 |

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

201 | 202 |

203 | 204 |
  • param: String selector

  • param: String text

  • return: Assertion for chaining

  • api: public

205 |
207 |
Assertion.prototype.one = function(selector, text){
 208 |   var elem = this.obj
 209 |     , elems = elem.find(selector)
 210 |     , len = elems.length;
 211 | 
 212 |   this.assert(
 213 |       1 == len
 214 |     , 'expected ' + j(elem) + ' to have one ' + i(selector) + ' tag, but has ' + n(len)
 215 |     , 'expected ' + j(elem) + ' to not have one ' + i(selector) + ' tag, but has ' + n(len));
 216 | 
 217 |   if (undefined != text) {
 218 |     elems.should.have.text(text);
 219 |   }
 220 | 
 221 |   return this;
 222 | };
223 |
227 |

Assert existance attr key with optional val.

228 | 229 |

230 | 231 |
  • param: String key

  • param: String val

  • return: Assertion for chaining

  • api: public

232 |
234 |
Assertion.prototype.attr = function(key, val){
 235 |   var elem = this.obj
 236 |     , attr = elem.attr(key);
 237 | 
 238 |   if (!val || (val &amp;&amp; !this.negate)) {
 239 |     this.assert(
 240 |         attr.length
 241 |       , 'expected ' + j(elem) + ' to have attribute ' + i(key)
 242 |       , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ', but has ' + i(attr));
 243 |   }
 244 | 
 245 |   if (val) {
 246 |     this.assert(
 247 |         val == attr
 248 |       , 'expected ' + j(elem) + ' to have attribute ' + i(key) + ' with ' + i(val) + ', but has ' + i(attr)
 249 |       , 'expected ' + j(elem) + ' to not have attribute ' + i(key) + ' with ' + i(val));
 250 |   }
 251 | 
 252 |   return this;
 253 | };
254 |
258 |

Assert presence of the given class name.

259 | 260 |

261 | 262 |
  • param: String name

  • return: Assertion for chaining

  • api: public

263 |
265 |
Assertion.prototype.class = function(name){
 266 |   var elem = this.obj;
 267 | 
 268 |   this.assert(
 269 |       elem.hasClass(name)
 270 |     , 'expected ' + j(elem) + ' to have class ' + i(name) + ', but has ' + i(elem.attr('class'))
 271 |     , 'expected ' + j(elem) + ' to not have class ' + i(name));
 272 | 
 273 |   return this;
 274 | };
275 |
279 |

Assert that header field has the given val.

280 | 281 |

282 | 283 |
  • param: String field

  • param: String val

  • return: Assertion for chaining

  • api: public

284 |
286 |
Assertion.prototype.header = function(field, val){
 287 |   this.obj.should.have.property('headers');
 288 |   this.obj.headers.should.have.property(field.toLowerCase(), val);
 289 |   return this;
 290 | };
291 |
295 |

Assert .statusCode of code.

296 | 297 |

298 | 299 |
  • param: Number code

  • return: Assertion for chaining

  • api: public

300 |
302 |
Assertion.prototype.status = function(code){
 303 |   this.obj.should.have.property('statusCode');
 304 |   var status = this.obj.statusCode;
 305 | 
 306 |   this.assert(
 307 |       code == status
 308 |     , 'expected response code of ' + code + ' ' + i(statusCodes[code])
 309 |       + ', but got ' + status + ' ' + i(statusCodes[status])
 310 |     , 'expected to not respond with ' + code + ' ' + i(statusCodes[code]));
 311 | 
 312 |   return this;
 313 | };
314 |
318 |

Assert id attribute.

319 | 320 |

321 | 322 |
  • param: String val

  • return: Assertion for chaining

  • api: public

323 |
325 |
Assertion.prototype.id = attr('id');
326 |
330 |

Assert title attribute.

331 | 332 |

333 | 334 |
  • param: String val

  • return: Assertion for chaining

  • api: public

335 |
337 |
Assertion.prototype.title = attr('title');
338 |
342 |

Assert alt attribute.

343 | 344 |

345 | 346 |
  • param: String val

  • return: Assertion for chaining

  • api: public

347 |
349 |
Assertion.prototype.alt = attr('alt');
350 |
354 |

Assert href attribute.

355 | 356 |

357 | 358 |
  • param: String val

  • return: Assertion for chaining

  • api: public

359 |
361 |
Assertion.prototype.href = attr('href');
362 |
366 |

Assert src attribute.

367 | 368 |

369 | 370 |
  • param: String val

  • return: Assertion for chaining

  • api: public

371 |
373 |
Assertion.prototype.src = attr('src');
374 |
378 |

Assert rel attribute.

379 | 380 |

381 | 382 |
  • param: String val

  • return: Assertion for chaining

  • api: public

383 |
385 |
Assertion.prototype.rel = attr('rel');
386 |
390 |

Assert media attribute.

391 | 392 |

393 | 394 |
  • param: String val

  • return: Assertion for chaining

  • api: public

395 |
397 |
Assertion.prototype.media = attr('media');
398 |
402 |

Assert name attribute.

403 | 404 |

405 | 406 |
  • param: String val

  • return: Assertion for chaining

  • api: public

407 |
409 |
Assertion.prototype.name = attr('name');
410 |
414 |

Assert action attribute.

415 | 416 |

417 | 418 |
  • param: String val

  • return: Assertion for chaining

  • api: public

419 |
421 |
Assertion.prototype.action = attr('action');
422 |
426 |

Assert method attribute.

427 | 428 |

429 | 430 |
  • param: String val

  • return: Assertion for chaining

  • api: public

431 |
433 |
Assertion.prototype.method = attr('method');
434 |
438 |

Assert value attribute.

439 | 440 |

441 | 442 |
  • param: String val

  • return: Assertion for chaining

  • api: public

443 |
445 |
Assertion.prototype.value = attr('value');
446 |
450 |

Assert enabled.

451 | 452 |

453 | 454 |
  • return: Assertion for chaining

  • api: public

455 |
457 |
Assertion.prototype.__defineGetter__('enabled', function(){
 458 |   var elem = this.obj
 459 |     , disabled = elem.attr('disabled');
 460 | 
 461 |   this.assert(
 462 |       !disabled
 463 |     , 'expected ' + j(elem) + ' to be enabled'
 464 |     , '<not implemented, use .disabled>');
 465 | 
 466 |   return this;
 467 | });
468 |
472 |

Assert disabled.

473 | 474 |

475 | 476 |
  • return: Assertion for chaining

  • api: public

477 |
479 |
Assertion.prototype.__defineGetter__('disabled', function(){
 480 |   var elem = this.obj
 481 |     , disabled = elem.attr('disabled');
 482 | 
 483 |   this.assert(
 484 |       disabled
 485 |     , 'expected ' + j(elem) + ' to be disabled'
 486 |     , '<not implemented, use .enabled>');
 487 | 
 488 |   return this;
 489 | });
490 |
494 |

Assert checked.

495 | 496 |

497 | 498 |
  • return: Assertion for chaining

  • api: public

499 |
501 |
Assertion.prototype.__defineGetter__('checked', bool('checked'));
502 |
506 |

Assert selected.

507 | 508 |

509 | 510 |
  • return: Assertion for chaining

  • api: public

511 |
513 |
Assertion.prototype.__defineGetter__('selected', bool('selected'));
514 |
518 |

Generate a boolean assertion function for the given attr name.

519 | 520 |

521 | 522 |
  • param: String name

  • return: Function

  • api: private

523 |
525 |
function bool(name) {
 526 |   return function(){
 527 |     var elem = this.obj;
 528 | 
 529 |     this.assert(
 530 |         elem.attr(name)
 531 |       , 'expected ' + j(elem) + ' to be ' + name
 532 |       , 'expected ' + j(elem) + ' to not be ' + name);
 533 | 
 534 |     return this;
 535 |   }
 536 | }
537 |
541 |

Generate an attr assertion function for the given attr name.

542 | 543 |

544 | 545 |
  • param: String name

  • return: Function

  • api: private

546 |
548 |
function attr(name) {
 549 |   return function(expected){
 550 |     var elem = this.obj
 551 |       , val = elem.attr(name);
 552 | 
 553 |     this.assert(
 554 |         expected == val
 555 |       , 'expected ' + j(elem) + ' to have ' + name + ' ' + i(expected) + ', but has ' + i(val)
 556 |       , 'expected ' + j(elem) + ' to not have ' + name + ' ' + i(expected));
 557 | 
 558 |     return this;
 559 |   }
 560 | }
 561 | 
562 |

browser

lib/browser.js
565 |

Module dependencies. 566 |

567 |
569 |
var EventEmitter = require('events').EventEmitter
 570 |   , Cookie = require('./cookie')
 571 |   , CookieJar = require('./cookie/jar')
 572 |   , jsdom = require('jsdom')
 573 |   , jQuery = require('../support/jquery')
 574 |   , http = require('http');
575 |
579 |

Starting portno. 580 |

581 |
583 |
var port = 8888;
584 |
588 |

Initialize a new Browser with the given html or server.

589 | 590 |

591 | 592 |
  • param: String | http.Server html

  • api: public

593 |
595 |
var Browser = module.exports = exports = function Browser(html) {
 596 |   this.history = [];
 597 |   this.cookieJar = new CookieJar;
 598 |   if ('string' == typeof html) {
 599 |     this.parse(html);
 600 |   } else {
 601 |     this.server = html;
 602 |     this.server.pending = 0;
 603 |     this.server.port = 8888;
 604 |   }
 605 | };
606 |
610 |

Inherit from EventEmitter.prototype. 611 |

612 |
614 |
Browser.prototype.__proto__ = EventEmitter.prototype;
615 |
619 |

Parse the given html and populate:

620 | 621 |
  • .source
  • .window
  • .jQuery
622 | 623 |

624 | 625 |
  • param: String html

  • api: public

626 |
628 |
Browser.prototype.parse = function(html){
 629 |   this.source = html;
 630 |   this.window = jsdom.jsdom(wrap(html)).createWindow();
 631 |   this.jQuery = jQuery.create(this.window);
 632 |   this.jQuery.browser = this.jQuery.fn.browser = this;
 633 |   require('./jquery')(this, this.jQuery);
 634 |   this.context = this.jQuery('*');
 635 | };
636 |
640 |

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

641 | 642 |

643 | 644 |
  • param: String selector

  • param: Function fn

  • return: Browser for chaining

  • api: public

645 |
647 |
Browser.prototype.within =
 648 | Browser.prototype.context = function(selector, fn){
 649 |   var prev = this.context;
 650 |   this.context = this.context.find(selector);
 651 |   fn();
 652 |   this.context = prev;
 653 |   return this;
 654 | };
655 |
659 |

Request path with method and callback fn(jQuery).

660 | 661 |

662 | 663 |
  • param: String path

  • param: String method

  • param: Object options

  • param: Function fn

  • return: Browser for chaining

  • api: public

664 |
666 |
Browser.prototype.request = function(method, path, options, fn, saveHistory){
 667 |   var self = this
 668 |     , server = this.server
 669 |     , host = '127.0.0.1'
 670 |     , headers = options.headers || {};
 671 | 
 672 |   // Ensure server
 673 |   if (!server) throw new Error('no .server present');
 674 |   ++server.pending;
 675 | 
 676 |   // HTTP client
 677 |   // TODO: options for headers, request body etc
 678 |   if (!server.fd) {
 679 |     server.listen(++port, host);
 680 |     server.client = http.createClient(port);
 681 |   }
 682 | 
 683 |   // Save history
 684 |   if (false !== saveHistory) this.history.push(path);
 685 | 
 686 |   // Cookies
 687 |   var cookies = this.cookieJar.get({ url: path });
 688 |   if (cookies.length) {
 689 |     headers.Cookie = cookies.map(function(cookie){
 690 |       return cookie.name + '=' + cookie.value;
 691 |     }).join('; ');
 692 |   }
 693 | 
 694 |   // Request
 695 |   headers.Host = host;
 696 |   var req = server.client.request(method, path, headers);
 697 |   req.on('response', function(res){
 698 |     var status = res.statusCode
 699 |       , buf = '';
 700 | 
 701 |     // Cookies
 702 |     if (res.headers['set-cookie']) {
 703 |       self.cookieJar.add(new Cookie(res.headers['set-cookie']));
 704 |     }
 705 | 
 706 |     // Success
 707 |     if (status &gt;= 200 &amp;&amp; status &lt; 300) {
 708 |       var contentType = res.headers['content-type'];
 709 | 
 710 |       // JSON support
 711 |       if (~contentType.indexOf('json')) {
 712 |         res.body = '';
 713 |         res.on('data', function(chunk){ res.body += chunk; });
 714 |         res.on('end', function(){
 715 |           try {
 716 |             res.body = JSON.parse(res.body);
 717 |             fn(res);
 718 |           } catch (err) {
 719 |             self.emit('error', err);
 720 |           }
 721 |         });
 722 |         return;
 723 |       }
 724 | 
 725 |       // Ensure html
 726 |       if (!~contentType.indexOf('text/html')) {
 727 |         return fn(res);
 728 |       }
 729 | 
 730 |       // Buffer html
 731 |       res.setEncoding('utf8');
 732 |       res.on('data', function(chunk){ buf += chunk; });
 733 |       res.on('end', function(){
 734 |         self.parse(buf);
 735 |         fn(res, function(selector){
 736 |           return self.context.find(selector);
 737 |         });
 738 |       });
 739 | 
 740 |     // Redirect
 741 |     } else if (status &gt;= 300 &amp;&amp; status &lt; 400) {
 742 |       var location = res.headers.location;
 743 |       self.emit('redirect', location);
 744 |       self.request('GET', location, options, fn);
 745 | 
 746 |     // Error
 747 |     } else {
 748 |       var err = new Error(
 749 |           method + ' ' + path
 750 |         + ' responded with '
 751 |         + status + ' "' + http.STATUS_CODES[status] + '"');
 752 |       self.emit('error', err);
 753 |     }
 754 |   });
 755 | 
 756 |   req.end(options.body);
 757 | 
 758 |   return this;
 759 | };
760 |
764 |

GET path and callback fn(jQuery).

765 | 766 |

767 | 768 |
  • param: String path

  • param: Object | Function options or fn

  • param: Function fn

  • return: Browser for chaining

  • api: public

769 |
771 |
Browser.prototype.get = 
 772 | Browser.prototype.visit =
 773 | Browser.prototype.open = function(path, options, fn, saveHistory){
 774 |   if ('function' == typeof options) {
 775 |     saveHistory = fn;
 776 |     fn = options;
 777 |     options = {};
 778 |   }
 779 |   return this.request('GET', path, options, fn, saveHistory);
 780 | };
781 |
785 |

POST path and callback fn(jQuery).

786 | 787 |

788 | 789 |
  • param: String path

  • param: Object | Function options or fn

  • param: Function fn

  • return: Browser for chaining

  • api: public

790 |
792 |
Browser.prototype.post = function(path, options, fn, saveHistory){
 793 |   if ('function' == typeof options) {
 794 |     saveHistory = fn;
 795 |     fn = options;
 796 |     options = {};
 797 |   }
 798 |   return this.request('POST', path, options, fn, saveHistory);
 799 | };
800 |
804 |

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

805 | 806 |

807 | 808 |
  • param: Number n

  • param: Function fn

  • return: Browser for chaining

  • api: public

809 |
811 |
Browser.prototype.back = function(n, fn){
 812 |   if ('function' == typeof n) fn = n, n = 1;
 813 |   while (n--) this.history.pop();
 814 |   return this.get(this.path, fn, false);
 815 | };
816 |
820 |

Locate elements via the given selector and locator supporting:

821 | 822 |
  • element text
  • element name attribute
  • css selector
823 | 824 |

825 | 826 |
  • param: String selector

  • param: String locator

  • return: jQuery

  • api: public

827 |
829 |
Browser.prototype.locate = function(selector, locator){
 830 |   var self = this
 831 |     , $ = this.jQuery;
 832 |   var elems = this.context.find(selector).filter(function(){
 833 |     var elem = $(this);
 834 |     return locator == elem.text()
 835 |       || locator == elem.attr('id')
 836 |       || locator == elem.attr('value')
 837 |       || locator == elem.attr('name')
 838 |       || elem.is(locator);
 839 |   });
 840 |   if (elems &amp;&amp; !elems.length) throw new Error('failed to locate "' + locator + '" in context of selector "' + selector + '"');
 841 |   return elems;
 842 | };
843 |
847 |

Return the current path.

848 | 849 |

850 | 851 |
  • return: String

  • api: public

852 |
854 |
Browser.prototype.__defineGetter__('path', function(){
 855 |   return this.history[this.history.length - 1];
 856 | });
857 |
861 |

Click the given locator and callback fn(res).

862 | 863 |

864 | 865 |
  • param: String locator

  • param: Function fn

  • return: Browser for chaining

  • api: public

866 |
868 |
Browser.prototype.click = function(locator, fn){
 869 |   return this.jQuery(this.locate(':submit, :button, a', locator)).click(fn, locator);
 870 | };
871 |
875 |

Assign val to the given locator.

876 | 877 |

878 | 879 |
  • param: String locator

  • param: String val

  • return: Browser for chaining

  • api: public

880 |
882 |
Browser.prototype.type = function(locator, val){
 883 |   this.jQuery(this.locate('input, textarea', locator)).val(val);
 884 |   return this;
 885 | };
886 |
890 |

Uncheck the checkbox with the given locator.

891 | 892 |

893 | 894 |
  • param: String locator

  • return: Assertion for chaining

  • api: public

895 |
897 |
Browser.prototype.uncheck = function(locator){
 898 |   this.locate(':checkbox', locator)[0].removeAttribute('checked');
 899 |   return this;
 900 | };
901 |
905 |

Check the checkbox with the given locator.

906 | 907 |

908 | 909 |
  • param: String locator

  • return: Assertion for chaining

  • api: public

910 |
912 |
Browser.prototype.check = function(locator){
 913 |   this.locate(':checkbox', locator)[0].setAttribute('checked', 'checked');
 914 |   return this;
 915 | };
916 |
920 |

Select options at locator.

921 | 922 |

923 | 924 |
  • param: String locator

  • param: String | Array select

  • return: Assertion for chaining

  • api: public

925 |
927 |
Browser.prototype.select = function(locator, options){
 928 |   this.jQuery(this.locate('select', locator)).select(options);
 929 |   return this;
 930 | };
931 |
935 |

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

936 | 937 |

938 | 939 |
  • param: String | Function locator

  • param: Function fn

  • return: Browser for chaining

  • api: public

940 |
942 |
Browser.prototype.submit = function(locator, fn){
 943 |   if ('function' == typeof locator) {
 944 |     fn = locator;
 945 |     locator = '*';
 946 |   }
 947 |   return this.jQuery(this.locate('form', locator)).submit(fn, locator);
 948 | };
949 |
953 |

Fill the given form fields and optional locator.

954 | 955 |

956 | 957 |
  • param: String locator

  • param: Object fields

  • return: Assertion for chaining

  • api: public

958 |
960 |
Browser.prototype.fill = function(locator, fields){
 961 |   if ('object' == typeof locator) {
 962 |     fields = locator;
 963 |     locator = '*';
 964 |   }
 965 |   this.jQuery(this.locate('form', locator)).fill(fields);
 966 |   return this;
 967 | };
968 |
972 |

Ensure html / body tags exist.

973 | 974 |

975 | 976 |
  • return: String

  • api: public

977 |
979 |
function wrap(html) {
 980 |   // body
 981 |   if (!~html.indexOf('<body')) {
 982 |     html = '<body>' + html + '</body>';
 983 |   }
 984 | 
 985 |   // html
 986 |   if (!~html.indexOf('<html')) {
 987 |     html = '<html>' + html + '</html>';
 988 |   }
 989 | 
 990 |   return html;
 991 | }
992 |

index

lib/cookie/index.js
995 |

Module dependencies. 996 |

997 |
999 |
var url = require('url');
1000 |
1004 |

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

1005 | 1006 |

1007 | 1008 |
  • param: String str

  • param: IncomingRequest req

  • api: private

1009 |
1011 |
var Cookie = exports = module.exports = function Cookie(str, req) {
1012 |   this.str = str;
1013 | 
1014 |   // First key is the name
1015 |   this.name = str.substr(0, str.indexOf('='));
1016 | 
1017 |   // Map the key/val pairs
1018 |   str.split(/ *; */).reduce(function(obj, pair){
1019 |     pair = pair.split(/ *= */);
1020 |     obj[pair[0]] = pair[1] || true;
1021 |     return obj;
1022 |   }, this);
1023 | 
1024 |   // Assign value
1025 |   this.value = this[this.name];
1026 | 
1027 |   // Expires
1028 |   this.expires = this.expires
1029 |     ? new Date(this.expires)
1030 |     : Infinity;
1031 | 
1032 |   // Default or trim path
1033 |   this.path = this.path
1034 |     ? this.path.trim()
1035 |     : url.parse(req.url).pathname;
1036 | };
1037 |
1041 |

Return the original cookie string.

1042 | 1043 |

1044 | 1045 |
  • return: String

  • api: public

1046 |
1048 |
Cookie.prototype.toString = function(){
1049 |   return this.str;
1050 | };
1051 | 
1052 |

jar

lib/cookie/jar.js
1055 |

Module dependencies. 1056 |

1057 |
1059 |
var url = require('url');
1060 |
1064 |

Initialize a new CookieJar.

1065 | 1066 |
  • api: private

1067 |
1069 |
var CookieJar = exports = module.exports = function CookieJar() {
1070 |   this.cookies = [];
1071 | };
1072 |
1076 |

Add the given cookie to the jar.

1077 | 1078 |

1079 | 1080 |
  • param: Cookie cookie

  • api: public

1081 |
1083 |
CookieJar.prototype.add = function(cookie){
1084 |   this.cookies.push(cookie);
1085 | };
1086 | 
1087 | CookieJar.prototype.get = function(req){
1088 |   var path = url.parse(req.url).pathname
1089 |     , now = new Date;
1090 |   return this.cookies.filter(function(cookie){
1091 |     return 0 == path.indexOf(cookie.path)
1092 |       &amp;&amp; now &lt; cookie.expires;
1093 |   });
1094 | };
1095 | 
1096 | 
1097 |

fill

lib/jquery/fill.js
1100 |

Select options. 1101 |

1102 |
1104 |
exports.select = function($, elems, val){
1105 |   elems.select(val);
1106 | };
1107 |
1111 |

Fill inputs:

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

1114 |
1116 |
exports.input = function($, elems, val){
1117 |   switch (elems.attr('type')) {
1118 |     case 'radio':
1119 |       elems.each(function(){
1120 |         var elem = $(this);
1121 |         val == elem.attr('value')
1122 |           ? elem.attr('checked', true)
1123 |           : elem.removeAttr('checked');
1124 |       });
1125 |       break;
1126 |     case 'checkbox':
1127 |       val
1128 |         ? elems.attr('checked', true)
1129 |         : elems.removeAttr('checked');
1130 |       break;
1131 |     default:
1132 |       elems.val(val);
1133 |   }
1134 | };
1135 |
1139 |

Fill textarea. 1140 |

1141 |
1143 |
exports.textarea = function($, elems, val){
1144 |   elems.val(val);
1145 | }
1146 |

index

lib/jquery/index.js
1149 |

Module dependencies. 1150 |

1151 |
1153 |
var parse = require('url').parse
1154 |   , fill = require('./fill');
1155 |
1159 |

Augment the given jQuery instance.

1160 | 1161 |

1162 | 1163 |
  • param: Browser browser

  • param: jQuery $

  • api: private

1164 |
1166 |
module.exports = function(browser, $){
1167 |
1171 |

Select the given options by text or value attr.

1172 | 1173 |

1174 | 1175 |
  • param: Array options

  • api: public

1176 |
1178 |
$.fn.select = function(options){
1179 |     if ('string' == typeof options) options = [options];
1180 | 
1181 |     this.find('option').filter(function(i, option){
1182 |       // via text or value
1183 |       var selected = ~options.indexOf($(option).text())
1184 |         || ~options.indexOf(option.getAttribute('value'));
1185 | 
1186 |       if (selected) {
1187 |         option.setAttribute('selected', 'selected');
1188 |       } else {
1189 |         option.removeAttribute('selected');
1190 |       }
1191 |     })
1192 | 
1193 |     return this;
1194 |   };
1195 |
1199 |

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

1201 | 1202 |
  • requests a tag href
  • requests form submit's parent form action
1203 | 1204 |

1205 | 1206 |
  • param: Function fn

  • api: public

1207 |
1209 |
$.fn.click = function(fn, locator){
1210 |     var url
1211 |       , prop = 'element'
1212 |       , method = 'get'
1213 |       , locator = locator || this.selector
1214 |       , options = {};
1215 | 
1216 |     switch (this[0].nodeName) {
1217 |       case 'A':
1218 |         prop = 'href';
1219 |         url = this.attr('href');
1220 |         break;
1221 |       case 'INPUT':
1222 |         if ('submit' == this.attr('type')) {
1223 |           var form = this.parent('form').first();
1224 |           method = form.attr('method') || 'get';
1225 |           url = form.attr('action') || parse($.browser.path).pathname;
1226 |           if ('get' == method) {
1227 |             url += '?' + form.serialize();
1228 |           } else  {
1229 |             options.body = form.serialize();
1230 |             options.headers = {
1231 |               'Content-Type': 'application/x-www-form-urlencoded'
1232 |             };
1233 |           }
1234 |         }
1235 |         break;
1236 |     }
1237 | 
1238 |     // Ensure url present
1239 |     if (!url) throw new Error('failed to click ' + locator + ', ' + prop + ' not present');
1240 | 
1241 |     // Perform request
1242 |     browser[method](url, options, fn);
1243 |     
1244 |     return this;
1245 |   };
1246 |
1250 |

Apply fill rules to the given fields.

1251 | 1252 |

1253 | 1254 |
  • param: Object fields

  • api: public

1255 |
1257 |
$.fn.fill = function(fields){
1258 |     for (var locator in fields) {
1259 |       var val = fields[locator]
1260 |         , elems = browser.locate('select, input, textarea', locator)
1261 |         , name = elems[0].nodeName.toLowerCase();
1262 |       fill[name]($, elems, val);
1263 |     }
1264 |     return this;
1265 |   };
1266 |
1270 |

Submit this form with the given callback fn.

1271 | 1272 |

1273 | 1274 |
  • param: Function fn

  • api: public

1275 |
1277 |
$.fn.submit = function(fn, locator){
1278 |     var submit = this.find(':submit');
1279 |     if (submit.length) {
1280 |       submit.click(fn, locator);
1281 |     } else {
1282 |       $('<input id="tobi-submit" type="submit" />')
1283 |         .appendTo(this)
1284 |         .click(fn, locator)
1285 |         .remove();
1286 |     }
1287 |   };
1288 | };
1289 |

tobi

lib/tobi.js
1292 |

Library version. 1293 |

1294 |
1296 |
exports.version = '0.0.1';
1297 |
1301 |

Expose Browser. 1302 |

1303 |
1305 |
exports.Browser = require('./browser');
1306 |
1310 |

Expose Cookie. 1311 |

1312 |
1314 |
exports.Cookie = require('./cookie');
1315 |
1319 |

Expose CookieJar. 1320 |

1321 |
1323 |
exports.CookieJar = require('./cookie/jar');
1324 |
1328 |

Initialize a new Browser. 1329 |

1330 |
1332 |
exports.createBrowser = function(str){
1333 |   return new exports.Browser(str);
1334 | };
1335 |
1339 |

Automated should.js support. 1340 |

1341 |
1343 |
try {
1344 |   require('should');
1345 |   require('./assertions/should');
1346 | } catch (err) {
1347 |   // Ignore
1348 | }
1349 |
--------------------------------------------------------------------------------