browserchannel
Version:
Google BrowserChannel server for NodeJS
1,050 lines (910 loc) • 70.5 kB
text/coffeescript
# # Unit tests for BrowserChannel server
#
# This contains all the unit tests to make sure the server works like it
# should. The tests are written using mocha - you can run them using
# % npm test
#
# For now I'm not going to add in any SSL testing code. I should probably generate
# a self-signed key pair, start the server using https and make sure that I can
# still use it.
#
# I'm also missing integration tests.
http = require 'http'
assert = require 'assert'
querystring = require 'querystring'
express = require 'express'
timer = require 'timerstub'
browserChannel = require('..').server
browserChannel._setTimerMethods timer
# This function provides an easy way for tests to create a new browserchannel server using
# connect().
#
# I'll create a new server in the setup function of all the tests, but some
# tests will need to customise the options, so they can just create another server directly.
createServer = (opts, method, callback) ->
# Its possible to use the browserChannel middleware without specifying an options
# object. This little createServer function will mirror that behaviour.
if typeof opts == 'function'
[method, callback] = [opts, method]
# I want to match up with how its actually going to be used.
bc = browserChannel method
else
bc = browserChannel opts, method
# The server is created using connect middleware. I'll simulate other middleware in
# the stack by adding a second handler which responds with 200, 'Other middleware' to
# any request.
app = express()
app.use bc
app.use (req, res, next) ->
# I might not actually need to specify the headers here... (If you don't, nodejs provides
# some defaults).
res.writeHead 200, 'OK', 'Content-Type': 'text/plain'
res.end 'Other middleware'
# Calling server.listen() without a port lets the OS pick a port for us. I don't
# know why more testing frameworks don't do this by default.
server = http.createServer(app).listen undefined, '127.0.0.1', ->
# Obviously, we need to know the port to be able to make requests from the server.
# The callee could check this itself using the server object, but it'll always need
# to know it, so its easier pulling the port out here.
port = server.address().port
callback server, port, bc
# Wait for the function to be called a given number of times, then call the callback.
#
# This useful little method has been stolen from ShareJS
expectCalls = (n, callback) ->
return callback() if n == 0
remaining = n
->
remaining--
if remaining == 0
callback()
else if remaining < 0
throw new Error "expectCalls called more than #{n} times"
# This returns a function that calls done() after it has been called n times. Its
# useful when you want a bunch of mini tests inside one test case.
makePassPart = (test, n) ->
expectCalls n, -> done()
# Most of these tests will make HTTP requests. A lot of the time, we don't care about the
# timing of the response, we just want to know what it was. This method will buffer the
# response data from an http response object and when the whole response has been received,
# send it on.
buffer = (res, callback) ->
data = []
res.on 'data', (chunk) ->
#console.warn chunk.toString()
data.push chunk.toString 'utf8'
res.on 'end', -> callback? data.join ''
# For some tests we expect certain data, delivered in chunks. Wait until we've
# received at least that much data and strcmp. The response will probably be used more,
# afterwards, so we'll make sure the listener is removed after we're done.
expect = (res, str, callback) ->
data = ''
res.on 'end', endlistener = ->
# This should fail - if the data was as long as str, we would have compared them
# already. Its important that we get an error message if the http connection ends
# before the string has been received.
console.warn 'Connection ended prematurely'
assert.strictEqual data, str
res.on 'data', listener = (chunk) ->
# I'm using string += here because the code is easier that way.
data += chunk.toString 'utf8'
#console.warn JSON.stringify data
#console.warn JSON.stringify str
if data.length >= str.length
assert.strictEqual data, str
res.removeListener 'data', listener
res.removeListener 'end', endlistener
callback()
# A bunch of tests require that we wait for a network connection to get established
# before continuing.
#
# We'll do that using a setTimeout with plenty of time. I hate adding delays, but I
# can't see another way to do this.
#
# This should be plenty of time. I might even be able to reduce this. Note that this
# is a real delay, not a stubbed out timer like we give to the server.
soon = (f) -> setTimeout f, 10
readLengthPrefixedString = (res, callback) ->
data = ''
length = null
res.on 'data', listener = (chunk) ->
data += chunk.toString 'utf8'
if length == null
# The number of bytes is written in an int on the first line.
lines = data.split '\n'
# If lines length > 1, then we've read the first newline, which was after the length
# field.
if lines.length > 1
length = parseInt lines.shift()
# Now we'll rewrite the data variable to not include the length.
data = lines.join '\n'
if data.length == length
res.removeListener 'data', listener
callback data
else if data.length > length
console.warn data
throw new Error "Read more bytes from stream than expected"
# The backchannel is implemented using a bunch of messages which look like this:
#
# ```
# 36
# [[0,["c","92208FBF76484C10",,8]
# ]
# ]
# ```
#
# (At least, thats what they look like using google's server. On mine, they're properly
# formatted JSON).
#
# Each message has a length string (in bytes) followed by a newline and some JSON data.
# They can optionally have extra chunks afterwards.
#
# This format is used for:
#
# - All XHR backchannel messages
# - The response to the initial connect (XHR or HTTP)
# - The server acknowledgement to forward channel messages
#
# This is not used when you're on IE, for normal backchannel requests. On IE, data is sent
# through javascript calls from an iframe.
readLengthPrefixedJSON = (res, callback) ->
readLengthPrefixedString res, (data) ->
callback JSON.parse(data)
# Copied from google's implementation. The contents of this aren't actually relevant,
# but I think its important that its pseudo-random so if the connection is compressed,
# it still recieves a bunch of bytes after the first message.
ieJunk = "7cca69475363026330a0d99468e88d23ce95e222591126443015f5f462d9a177186c8701fb45a6ffe\
e0daf1a178fc0f58cd309308fba7e6f011ac38c9cdd4580760f1d4560a84d5ca0355ecbbed2ab715a3350fe0c47\
9050640bd0e77acec90c58c4d3dd0f5cf8d4510e68c8b12e087bd88cad349aafd2ab16b07b0b1b8276091217a44\
a9fe92fedacffff48092ee693af\n"
suite 'server', ->
# #### setup
#
# Before each test has run, we'll start a new server. The server will only live
# for that test and then it'll be torn down again.
#
# This makes the tests run more slowly, but not slowly enough that I care.
setup (callback) ->
# This will make sure there's no pesky setTimeouts from previous tests kicking around.
# I could instead do a timer.waitAll() in tearDown to let all the timers run & time out
# the running sessions. It shouldn't be a big deal.
timer.clearAll()
# When you instantiate browserchannel, you specify a function which gets called
# with each session that connects. I'll proxy that function call to a local function
# which tests can override.
@onSession = (session) ->
# The proxy is inline here. Also, I <3 coffeescript's (@server, @port) -> syntax here.
# That will automatically set this.server and this.port to the callback arguments.
#
# Actually calling the callback starts the test.
createServer ((session) => @onSession session), (@server, @port, @bc) =>
# TODO - This should be exported from lib/server
@standardHeaders=
'Content-Type': 'text/plain'
'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate'
'Pragma': 'no-cache'
'Expires': 'Fri, 01 Jan 1990 00:00:00 GMT'
'X-Content-Type-Options': 'nosniff'
# I'll add a couple helper methods for tests to easily message the server.
@get = (path, callback) =>
http.get {host:'localhost', path, @port}, callback
@post = (path, data, callback) =>
req = http.request {method:'POST', host:'localhost', path, @port}, callback
req.end data
# One of the most common tasks in tests is to create a new session for
# some reason. @connect is a little helper method for that. It simply sends the
# http POST to create a new session and calls the callback when the session has been
# created by the server.
#
# It also makes @onSession throw an error - very few tests need multiple sessions,
# so I special case them when they do.
#
# Its kind of annoying - for a lot of tests I need to do custom logic in the @post
# handler *and* custom logic in @onSession. So, callback can be an object specifying
# callbacks for each if you want that. Its a shame though, it makes this function
# kinda ugly :/
@connect = (callback) =>
# This connect helper method is only useful if you don't care about the initial
# post response.
@post '/channel/bind?VER=8&RID=1000&t=1', 'count=0'
@onSession = (@session) =>
@onSession = -> throw new Error 'onSession() called - I didn\'t expect another session to be created'
# Keep this bound. I think there's a fancy way to do this in coffeescript, but
# I'm not sure what it is.
callback.call this
# Finally, start the test.
callback()
teardown (callback) ->
# #### tearDown
#
# This is called after each tests is done. We'll tear down the server we just created.
#
# The next test is run once the callback is called. I could probably chain the next
# test without waiting for close(), but then its possible that an exception thrown
# in one test will appear after the next test has started running. Its easier to debug
# like this.
@server.close callback
# The server hosts the client-side javascript at /channel.js. It should have headers set to tell
# browsers its javascript.
#
# Its self contained, with no dependancies on anything. It would be nice to test it as well,
# but we'll do that elsewhere.
test 'The javascript is hosted at channel/bcsocket.js', (done) ->
@get '/channel/bcsocket.js', (response) ->
assert.strictEqual response.statusCode, 200
assert.strictEqual response.headers['content-type'], 'application/javascript'
assert.ok response.headers['etag']
buffer response, (data) ->
# Its about 47k. If the size changes too drastically, I want to know about it.
assert.ok data.length > 45000, "Client is unusually small (#{data.length} bytes)"
assert.ok data.length < 50000, "Client is bloaty (#{data.length} bytes)"
done()
# # Testing channel tests
#
# The first thing a client does when it connects is issue a GET on /test/?mode=INIT.
# The server responds with an array of [basePrefix or null,blockedPrefix or null]. Blocked
# prefix isn't supported by node-browerchannel and by default no basePrefix is set. So with no
# options specified, this GET should return [null,null].
test 'GET /test/?mode=INIT with no baseprefix set returns [null, null]', (done) ->
@get '/channel/test?VER=8&MODE=init', (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, '[null,null]'
done()
# If a basePrefix is set in the options, make sure the server returns it.
test 'GET /test/?mode=INIT with a basePrefix set returns [basePrefix, null]', (done) ->
# You can specify a bunch of host prefixes. If you do, the server will randomly pick between them.
# I don't know if thats actually useful behaviour, but *shrug*
# I should probably write a test to make sure all host prefixes will be chosen from time to time.
createServer hostPrefixes:['chan'], (->), (server, port) ->
http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, '["chan",null]'
# I'm being slack here - the server might not close immediately. I could make done()
# dependant on it, but I can't be bothered.
server.close()
done()
# Setting a custom url endpoint to bind node-browserchannel to should make it respond at that url endpoint
# only.
test 'The test channel responds at a bound custom endpoint', (done) ->
createServer base:'/foozit', (->), (server, port) ->
http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, '[null,null]'
server.close()
done()
# Some people will miss out on the leading slash in the URL when they bind browserchannel to a custom
# url. That should work too.
test 'binding the server to a custom url without a leading slash works', (done) ->
createServer base:'foozit', (->), (server, port) ->
http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, '[null,null]'
server.close()
done()
# Its tempting to think that you need a trailing slash on your URL prefix as well. You don't, but that should
# work too.
test 'binding the server to a custom url with a trailing slash works', (done) ->
# Some day, the copy+paste police are gonna get me. I don't feel *so* bad doing it for tests though, because
# it helps readability.
createServer base:'foozit/', (->), (server, port) ->
http.get {path:'/foozit/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, '[null,null]'
server.close()
done()
# You can control the CORS header ('Access-Control-Allow-Origin') using options.cors.
test 'CORS header is not sent if its not set in the options', (done) ->
@get '/channel/test?VER=8&MODE=init', (response) ->
assert.strictEqual response.headers['access-control-allow-origin'], undefined
assert.strictEqual response.headers['access-control-allow-credentials'], undefined
response.socket.end()
done()
test 'CORS headers are sent during the initial phase if set in the options', (done) ->
createServer cors:'foo.com', corsAllowCredentials:true, (->), (server, port) ->
http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.headers['access-control-allow-origin'], 'foo.com'
assert.strictEqual response.headers['access-control-allow-credentials'], 'true'
buffer response
server.close()
done()
test 'CORS headers can be set using a function', (done) ->
createServer cors: (-> 'something'), (->), (server, port) ->
http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.headers['access-control-allow-origin'], 'something'
buffer response
server.close()
done()
test 'CORS header is set on the backchannel response', (done) ->
server = port = null
sessionCreated = (session) ->
# Make the backchannel flush as soon as its opened
session.send "flush"
req = http.get {path:"/channel/bind?VER=8&RID=rpc&SID=#{session.id}&AID=0&TYPE=xmlhttp&CI=0", host:'localhost', port:port}, (res) =>
assert.strictEqual res.headers['access-control-allow-origin'], 'foo.com'
assert.strictEqual res.headers['access-control-allow-credentials'], 'true'
req.abort()
server.close()
done()
createServer cors:'foo.com', corsAllowCredentials:true, sessionCreated, (_server, _port) ->
[server, port] = [_server, _port]
req = http.request {method:'POST', path:'/channel/bind?VER=8&RID=1000&t=1', host:'localhost', port:port}, (res) =>
req.end 'count=0'
# This test is just testing one of the error responses for the presence of
# the CORS header. It doesn't test all of the ports, and doesn't test IE.
# (I'm not actually sure if CORS headers are needed for IE stuff)
suite 'CORS header is set in error responses', ->
setup (callback) ->
createServer cors:'foo.com', corsAllowCredentials:true, (->), (@corsServer, @corsPort) =>
callback()
teardown ->
@corsServer.close()
testResponse = (done, req, res) ->
assert.strictEqual res.statusCode, 400
assert.strictEqual res.headers['access-control-allow-origin'], 'foo.com'
assert.strictEqual res.headers['access-control-allow-credentials'], 'true'
buffer res, (data) ->
assert.ok data.indexOf('Unknown SID') > 0
req.abort()
done()
test 'backChannel', (done) ->
req = http.get {path:'/channel/bind?VER=8&RID=rpc&SID=madeup&AID=0&TYPE=xmlhttp&CI=0', host:'localhost', port:@corsPort}, (res) =>
testResponse done, req, res
test 'forwardChannel', (done) ->
req = http.request {method:'POST', path:'/channel/bind?VER=8&RID=1001&SID=junkyjunk&AID=0', host:'localhost', port:@corsPort}, (res) =>
testResponse done, req, res
req.end 'count=0'
#@post "/channel/bind?VER=8&RID=1001&SID=junkyjunk&AID=0", 'count=0', testResponse(done)
test 'Additional headers can be specified in the options', (done) ->
createServer headers:{'X-Foo':'bar'}, (->), (server, port) ->
http.get {path:'/channel/test?VER=8&MODE=init', host: 'localhost', port: port}, (response) ->
assert.strictEqual response.headers['x-foo'], 'bar'
server.close()
done()
# Interestingly, the CORS header isn't required for old IE (type=html) requests because they're loaded using
# iframes anyway. (Though this should really be tested).
# node-browserchannel is only responsible for URLs with the specified (or default) prefix. If a request
# comes in for a URL outside of that path, it should be passed along to subsequent connect middleware.
#
# I've set up the createServer() method above to send 'Other middleware' if browserchannel passes
# the response on to the next handler.
test 'getting a url outside of the bound range gets passed to other middleware', (done) ->
@get '/otherapp', (response) ->
assert.strictEqual response.statusCode, 200
buffer response, (data) ->
assert.strictEqual data, 'Other middleware'
done()
# I decided to make URLs inside the bound range return 404s directly. I can't guarantee that no future
# version of node-browserchannel won't add more URLs in the zone, so its important that users don't decide
# to start using arbitrary other URLs under channel/.
#
# That design decision makes it impossible to add a custom 404 page to /channel/FOO, but I don't think thats a
# big deal.
test 'getting a wacky url inside the bound range returns 404', (done) ->
@get '/channel/doesnotexist', (response) ->
assert.strictEqual response.statusCode, 404
response.socket.end()
done()
# For node-browserchannel, we also accept JSON in forward channel POST data. To tell the client that
# this is supported, we add an `X-Accept: application/json; application/x-www-form-urlencoded` header
# in phase 1 of the test API.
test 'The server sends accept:JSON header during test phase 1', (done) ->
@get '/channel/test?VER=8&MODE=init', (res) ->
assert.strictEqual res.headers['x-accept'], 'application/json; application/x-www-form-urlencoded'
res.socket.end()
done()
# All the standard headers should be sent along with X-Accept
test 'The server sends standard headers during test phase 1', (done) ->
@get '/channel/test?VER=8&MODE=init', (res) =>
assert.strictEqual res.headers[k.toLowerCase()].toLowerCase(), v.toLowerCase() for k,v of @standardHeaders
res.socket.end()
done()
# ## Testing phase 2
#
# I should really sort the above tests better.
#
# Testing phase 2 the client GETs /channel/test?VER=8&TYPE= [html / xmlhttp] &zx=558cz3evkwuu&t=1 [&DOMAIN=xxxx]
#
# The server sends '11111' <2 second break> '2'. If you use html encoding instead, the server sends the client
# a webpage which calls:
#
# document.domain='mail.google.com';
# parent.m('11111');
# parent.m('2');
# parent.d();
suite 'Getting test phase 2 returns 11111 then 2', ->
makeTest = (type, message1, message2) -> (done) ->
@get "/channel/test?VER=8&TYPE=#{type}", (response) ->
assert.strictEqual response.statusCode, 200
expect response, message1, ->
# Its important to make sure that message 2 isn't sent too soon (<2 seconds).
# We'll advance the server's clock forward by just under 2 seconds and then wait a little bit
# for messages from the client. If we get a message during this time, throw an error.
response.on 'data', f = -> throw new Error 'should not get more data so early'
timer.wait 1999, ->
soon ->
response.removeListener 'data', f
expect response, message2, ->
response.once 'end', -> done()
timer.wait 1
test 'xmlhttp', makeTest 'xmlhttp', '11111', '2'
# I could write this test using JSDom or something like that, and parse out the HTML correctly.
# ... but it would be way more complicated (and no more correct) than simply comparing the resulting
# strings.
test 'html', makeTest('html',
# These HTML responses are identical to what I'm getting from google's servers. I think the seemingly
# random sequence is just so network framing doesn't try and chunk up the first packet sent to the server
# or something like that.
"""<html><body><script>try {parent.m("11111")} catch(e) {}</script>\n#{ieJunk}""",
'''<script>try {parent.m("2")} catch(e) {}</script>
<script>try {parent.d(); }catch (e){}</script>\n''')
# If a client is connecting with a host prefix, the server sets the iframe's document.domain to match
# before sending actual data.
'html with a host prefix': makeTest('html&DOMAIN=foo.bar.com',
# I've made a small change from google's implementation here. I'm using double quotes `"` instead of
# single quotes `'` because its easier to encode. (I can't just wrap the string in quotes because there
# are potential XSS vulnerabilities if I do that).
"""<html><body><script>try{document.domain="foo.bar.com";}catch(e){}</script>
<script>try {parent.m("11111")} catch(e) {}</script>\n#{ieJunk}""",
'''<script>try {parent.m("2")} catch(e) {}</script>
<script>try {parent.d(); }catch (e){}</script>\n''')
# IE doesn't parse the HTML in the response unless the Content-Type is text/html
test 'Using type=html sets Content-Type: text/html', (done) ->
r = @get "/channel/test?VER=8&TYPE=html", (response) ->
assert.strictEqual response.headers['content-type'], 'text/html'
r.abort()
done()
# IE should also get the standard headers
test 'Using type=html gets the standard headers', (done) ->
r = @get "/channel/test?VER=8&TYPE=html", (response) =>
for k, v of @standardHeaders when k isnt 'Content-Type'
assert.strictEqual response.headers[k.toLowerCase()].toLowerCase(), v.toLowerCase()
r.abort()
done()
# node-browserchannel is only compatible with browserchannel client version 8. I don't know whats changed
# since old versions (maybe v6 would be easy to support) but I don't care. If the client specifies
# an old version, we'll die with an error.
# The alternate phase 2 URL style should have the same behaviour if the version is old or unspecified.
#
# Google's browserchannel server still works if you miss out on specifying the version - it defaults
# to version 1 (which maybe didn't have version numbers in the URLs). I'm kind of impressed that
# all that code still works.
suite 'Getting /test/* without VER=8 returns an error', ->
# All these tests look 95% the same. Instead of writing the same test all those times, I'll use this
# little helper method to generate them.
check400 = (path) -> (done) ->
@get path, (response) ->
assert.strictEqual response.statusCode, 400
response.socket.end()
done()
test 'phase 1, ver 7', check400 '/channel/test?VER=7&MODE=init'
test 'phase 1, no version', check400 '/channel/test?MODE=init'
test 'phase 2, ver 7, xmlhttp', check400 '/channel/test?VER=7&TYPE=xmlhttp'
test 'phase 2, no version, xmlhttp', check400 '/channel/test?TYPE=xmlhttp'
# For HTTP connections (IE), the error is sent a different way. Its kinda complicated how the error
# is sent back, so for now I'm just going to ignore checking it.
test 'phase 2, ver 7, http', check400 '/channel/test?VER=7&TYPE=html'
test 'phase 2, no version, http', check400 '/channel/test?TYPE=html'
# > At the moment the server expects the client will add a zx=###### query parameter to all requests.
# The server isn't strict about this, so I'll ignore it in the tests for now.
# # Server connection tests
# These tests make server sessions by crafting raw HTTP queries. I'll make another set of
# tests later which spam the server with a million fake clients.
#
# To start with, simply connect to a server using the BIND API. A client sends a server a few parameters:
#
# - **CVER**: Client application version
# - **RID**: Client-side generated random number, which is the initial sequence number for the
# client's requests.
# - **VER**: Browserchannel protocol version. Must be 8.
# - **t**: The connection attempt number. This is currently ignored by the BC server. (I'm not sure
# what google's implementation does with this).
test 'The server makes a new session if the client POSTs the right connection stuff', (done) ->
id = null
onSessionCalled = no
# When a request comes in, we should get the new session through the server API.
#
# We need this session in order to find out the session ID, which should match up with part of the
# server's response.
@onSession = (session) ->
assert.ok session
assert.strictEqual typeof session.id, 'string'
assert.strictEqual session.state, 'init'
assert.strictEqual session.appVersion, '99'
assert.deepEqual session.address, '127.0.0.1'
assert.strictEqual typeof session.headers, 'object'
id = session.id
session.on 'map', -> throw new Error 'Should not have received data'
onSessionCalled = yes
# The client starts a BC connection by POSTing to /bind? with no session ID specified.
# The client can optionally send data here, but in this case it won't (hence the `count=0`).
@post '/channel/bind?VER=8&RID=1000&CVER=99&t=1&junk=asdfasdf', 'count=0', (res) =>
expected = (JSON.stringify [[0, ['c', id, null, 8]]]) + '\n'
buffer res, (data) ->
# Even for old IE clients, the server responds in length-prefixed JSON style.
assert.strictEqual data, "#{expected.length}\n#{expected}"
assert.ok onSessionCalled
done()
# Once a client's session id is sent, the session moves to the `ok` state. This happens after onSession is
# called (so onSession can send messages to the client immediately).
#
# I'm starting to use the @connect method here, which just POSTs locally to create a session, sets @session and
# calls its callback.
test 'A session has state=ok after onSession returns', (done) -> @connect ->
@session.on 'state changed', (newState, oldState) =>
assert.strictEqual oldState, 'init'
assert.strictEqual newState, 'ok'
assert.strictEqual @session.state, 'ok'
done()
# The CVER= property is optional during client connections. If its left out, session.appVersion is
# null.
test 'A session connects ok even if it doesnt specify an app version', (done) ->
id = null
onSessionCalled = no
@onSession = (session) ->
assert.strictEqual session.appVersion, null
id = session.id
session.on 'map', -> throw new Error 'Should not have received data'
onSessionCalled = yes
@post '/channel/bind?VER=8&RID=1000&t=1&junk=asdfasdf', 'count=0', (res) =>
expected = (JSON.stringify [[0, ['c', id, null, 8]]]) + '\n'
buffer res, (data) ->
assert.strictEqual data, "#{expected.length}\n#{expected}"
assert.ok onSessionCalled
done()
# Once again, only VER=8 works.
suite 'Connecting with a version thats not 8 breaks', ->
# This will POST to the requested path and make sure the response sets status 400
check400 = (path) -> (done) ->
@post path, 'count=0', (response) ->
assert.strictEqual response.statusCode, 400
response.socket.end()
done()
test 'no version', check400 '/channel/bind?RID=1000&t=1'
test 'previous version', check400 '/channel/bind?VER=7&RID=1000&t=1'
# This time, we'll send a map to the server during the initial handshake. This should be received
# by the server as normal.
test 'The client can post messages to the server during initialization', (done) ->
@onSession = (session) ->
session.on 'map', (data) ->
assert.deepEqual data, {k:'v'}
done()
@post '/channel/bind?VER=8&RID=1000&t=1', 'count=1&ofs=0&req0_k=v', (res) =>
res.socket.end()
# The data received by the server should be properly URL decoded and whatnot.
test 'Server messages are properly URL decoded', (done) ->
@onSession = (session) ->
session.on 'map', (data) ->
assert.deepEqual data, {"_int_^&^%#net":'hi"there&&\nsam'}
done()
@post('/channel/bind?VER=8&RID=1000&t=1',
'count=1&ofs=0&req0__int_%5E%26%5E%25%23net=hi%22there%26%26%0Asam', (res) -> res.socket.end())
# After a client connects, it can POST data to the server using URL-encoded POST data. This data
# is sent by POSTing to /bind?SID=....
#
# The data looks like this:
#
# count=5&ofs=1000&req0_KEY1=VAL1&req0_KEY2=VAL2&req1_KEY3=req1_VAL3&...
test 'The client can post messages to the server after initialization', (done) -> @connect ->
@session.on 'map', (data) ->
assert.deepEqual data, {k:'v'}
done()
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) =>
res.socket.end()
# When the server gets a forwardchannel request, it should reply with a little array saying whats
# going on.
test 'The server acknowledges forward channel messages correctly', (done) -> @connect ->
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) =>
readLengthPrefixedJSON res, (data) =>
# The server responds with [backchannelMissing ? 0 : 1, lastSentAID, outstandingBytes]
assert.deepEqual data, [0, 0, 0]
done()
# If the server has an active backchannel, it responds to forward channel requests notifying the client
# that the backchannel connection is alive and well.
test 'The server tells the client if the backchannel is alive', (done) -> @connect ->
# This will fire up a backchannel connection to the server.
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp", (res) =>
# The client shouldn't get any data through the backchannel.
res.on 'data', -> throw new Error 'Should not get data through backchannel'
# Unfortunately, the GET request is sent *after* the POST, so we have to wrap the
# post in a timeout to make sure it hits the server after the backchannel connection is
# established.
soon =>
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_k=v', (res) =>
readLengthPrefixedJSON res, (data) =>
# This time, we get a 1 as the first argument because the backchannel connection is
# established.
assert.deepEqual data, [1, 0, 0]
# The backchannel hasn't gotten any data yet. It'll spend 15 seconds or so timing out
# if we don't abort it manually.
# As of nodejs 0.6, if you abort() a connection, it can emit an error.
req.on 'error', ->
req.abort()
done()
# The forward channel response tells the client how many unacknowledged bytes there are, so it can decide
# whether or not it thinks the backchannel is dead.
test 'The server tells the client how much unacknowledged data there is in the post response', (done) -> @connect ->
process.nextTick =>
# I'm going to send a few messages to the client and acknowledge the first one in a post response.
@session.send 'message 1'
@session.send 'message 2'
@session.send 'message 3'
# We'll make a backchannel and get the data
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
# After the data is received, I'll acknowledge the first message using an empty POST
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=1", 'count=0', (res) =>
readLengthPrefixedJSON res, (data) =>
# We should get a response saying "The backchannel is connected", "The last message I sent was 3"
# "messages 2 and 3 haven't been acknowledged, here's their size"
assert.deepEqual data, [1, 3, 25]
req.abort()
done()
# When the user calls send(), data is queued by the server and sent into the next backchannel connection.
#
# The server will use the initial establishing connection if thats available, or it'll send it the next
# time the client opens a backchannel connection.
test 'The server returns data on the initial connection when send is called immediately', (done) ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
@onSession = (@session) =>
@session.send testData
# I'm not using @connect because we need to know about the response to the first POST.
@post '/channel/bind?VER=8&RID=1000&t=1', 'count=0', (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[0, ['c', @session.id, null, 8]], [1, testData]]
done()
test 'The server escapes tricky characters before sending JSON over the wire', (done) ->
testData = {'a': 'hello\u2028\u2029there\u2028\u2029'}
@onSession = (@session) =>
@session.send testData
# I'm not using @connect because we need to know about the response to the first POST.
@post '/channel/bind?VER=8&RID=1000&t=1', 'count=0', (res) =>
readLengthPrefixedString res, (data) =>
assert.deepEqual data, """[[0,["c","#{@session.id}",null,8]],[1,{"a":"hello\\u2028\\u2029there\\u2028\\u2029"}]]\n"""
done()
test 'The server buffers data if no backchannel is available', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
# The first response to the server is sent after this method returns, so if we send the data
# in process.nextTick, it'll get buffered.
process.nextTick =>
@session.send testData
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[1, testData]]
req.abort()
done()
# This time, we'll fire up the back channel first (and give it time to get established) _then_
# send data through the session.
test 'The server returns data through the available backchannel when send is called later', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
# Fire off the backchannel request as soon as the client has connected
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) ->
#res.on 'data', (chunk) -> console.warn chunk.toString()
readLengthPrefixedJSON res, (data) ->
assert.deepEqual data, [[1, testData]]
req.abort()
done()
# Send the data outside of the get block to make sure it makes it through.
soon => @session.send testData
# The server should call the send callback once the data has been confirmed by the client.
#
# We'll try sending three messages to the client. The first message will be sent during init and the
# third message will not be acknowledged. Only the first two message callbacks should get called.
test 'The server calls send callback once data is acknowledged', (done) -> @connect ->
lastAck = null
@session.send [1], ->
assert.strictEqual lastAck, null
lastAck = 1
process.nextTick =>
@session.send [2], ->
assert.strictEqual lastAck, 1
# I want to give the test time to die
soon -> done()
# This callback should actually get called with an error after the client times out. ... but I'm not
# giving timeouts a chance to run.
@session.send [3], -> throw new Error 'Should not call unacknowledged send callback'
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[2, [2]], [3, [3]]]
# Ok, now we'll only acknowledge the second message by sending AID=2
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=2", 'count=0', (res) =>
res.socket.end()
req.abort()
# This tests for a regression - if the stop callback closed the connection, the server was crashing.
test 'The send callback can stop the session', (done) -> @connect ->
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) =>
# Acknowledge the stop message
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=2", 'count=0', (res) =>
res.socket.end()
@session.stop =>
@session.close()
soon ->
req.abort()
done()
# If there's a proxy in the way which chunks up responses before sending them on, the client adds a
# &CI=1 argument on the backchannel. This causes the server to end the HTTP query after each message
# is sent, so the data is sent to the session.
test 'The backchannel is closed after each packet if chunking is turned off', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
process.nextTick =>
@session.send testData
# Instead of the usual CI=0 we're passing CI=1 here.
@get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=1", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[1, testData]]
res.on 'end', -> done()
# Normally, the server doesn't close the connection after each backchannel message.
test 'The backchannel is left open if CI=0', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
process.nextTick =>
@session.send testData
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[1, testData]]
# After receiving the data, the client shouldn't close the connection. (At least, not unless
# it times out naturally).
res.on 'end', -> throw new Error 'connection should have stayed open'
soon ->
res.removeAllListeners 'end'
req.abort()
done()
# On IE, the data is all loaded using iframes. The backchannel spits out data using inline scripts
# in an HTML page.
#
# I've written this test separately from the tests above, but it would really make more sense
# to rerun the same set of tests in both HTML and XHR modes to make sure the behaviour is correct
# in both instances.
test 'The server gives the client correctly formatted backchannel data if TYPE=html', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
process.nextTick =>
@session.send testData
# The type is specified as an argument here in the query string. For this test, I'm making
# CI=1, because the test is easier to write that way.
#
# In truth, I don't care about IE support as much as support for modern browsers. This might
# be a mistake.. I'm not sure. IE9's XHR support should work just fine for browserchannel,
# though google's BC client doesn't use it.
@get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=html&CI=1", (res) =>
expect res,
# Interestingly, google doesn't double-encode the string like this. Instead of turning
# quotes `"` into escaped quotes `\"`, it uses unicode encoding to turn them into \42 and
# stuff like that. I'm not sure why they do this - it produces the same effect in IE8.
# I should test it in IE6 and see if there's any problems.
"""<html><body><script>try {parent.m(#{JSON.stringify JSON.stringify([[1, testData]]) + '\n'})} catch(e) {}</script>
#{ieJunk}<script>try {parent.d(); }catch (e){}</script>\n""", =>
# Because I'm lazy, I'm going to chain on a test to make sure CI=0 works as well.
data2 = {other:'data'}
@session.send data2
# I'm setting AID=1 here to indicate that the client has seen array 1.
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=html&CI=0", (res) =>
expect res,
"""<html><body><script>try {parent.m(#{JSON.stringify JSON.stringify([[2, data2]]) + '\n'})} catch(e) {}</script>
#{ieJunk}""", =>
req.abort()
done()
# If there's a basePrefix set, the returned HTML sets `document.domain = ` before sending messages.
# I'm super lazy, and just copy+pasting from the test above. There's probably a way to factor these tests
# nicely, but I'm not in the mood to figure it out at the moment.
test 'The server sets the domain if we have a domain set', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
process.nextTick =>
@session.send testData
# This time we're setting DOMAIN=X, and the response contains a document.domain= block. Woo.
@get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=html&CI=1&DOMAIN=foo.com", (res) =>
expect res,
"""<html><body><script>try{document.domain=\"foo.com\";}catch(e){}</script>
<script>try {parent.m(#{JSON.stringify JSON.stringify([[1, testData]]) + '\n'})} catch(e) {}</script>
#{ieJunk}<script>try {parent.d(); }catch (e){}</script>\n""", =>
data2 = {other:'data'}
@session.send data2
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=html&CI=0&DOMAIN=foo.com", (res) =>
expect res,
# Its interesting - in the test channel, the ie junk comes right after the document.domain= line,
# but in a backchannel like this it comes after. The behaviour here is the same in google's version.
#
# I'm not sure if its actually significant though.
"""<html><body><script>try{document.domain=\"foo.com\";}catch(e){}</script>
<script>try {parent.m(#{JSON.stringify JSON.stringify([[2, data2]]) + '\n'})} catch(e) {}</script>
#{ieJunk}""", =>
req.abort()
done()
# If a client thinks their backchannel connection is closed, they might open a second backchannel connection.
# In this case, the server should close the old one and resume sending stuff using the new connection.
test 'The server closes old backchannel connections', (done) -> @connect ->
testData = ['hello', 'there', null, 1000, {}, [], [555]]
process.nextTick =>
@session.send testData
# As usual, we'll get the sent data through the backchannel connection. The connection is kept open...
@get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
# ... and the data has been read. Now we'll open another connection and check that the first connection
# gets closed.
req2 = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res2) =>
res.on 'end', ->
req2.on 'error', ->
req2.abort()
done()
# The client attaches a sequence number (*RID*) to every message, to make sure they don't end up out-of-order at
# the server's end.
#
# We'll purposefully send some messages out of order and make sure they're held and passed through in order.
#
# Gogo gadget reimplementing TCP.
test 'The server orders forwardchannel messages correctly using RIDs', (done) -> @connect ->
# @connect sets RID=1000.
# We'll send 2 maps, the first one will be {v:1} then {v:0}. They should be swapped around by the server.
lastVal = 0
@session.on 'map', (map) ->
assert.strictEqual map.v, "#{lastVal++}", 'messages arent reordered in the server'
done() if map.v == '2'
# First, send `[{v:2}]`
@post "/channel/bind?VER=8&RID=1002&SID=#{@session.id}&AID=0", 'count=1&ofs=2&req0_v=2', (res) =>
res.socket.end()
# ... then `[{v:0}, {v:1}]` a few MS later.
soon =>
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=2&ofs=0&req0_v=0&req1_v=1', (res) =>
res.socket.end()
# Again, think of browserchannel as TCP on top of UDP...
test 'Repeated forward channel messages are discarded', (done) -> @connect ->
gotMessage = false
# The map must only be received once.
@session.on 'map', (map) ->
if gotMessage == false
gotMessage = true
else
throw new Error 'got map twice'
# POST the maps twice.
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end()
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end()
# Wait 50 milliseconds for the map to (maybe!) be received twice, then pass.
soon ->
assert.strictEqual gotMessage, true
done()
# The client can retry failed forwardchannel requests with additional maps. We may have gotten the failed
# request. An error could have occurred when we reply.
test 'Repeat forward channel messages can contain extra maps', (done) -> @connect ->
# We should get exactly 2 maps, {v:0} then {v:1}
maps = []
@session.on 'map', (map) ->
maps.push map
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=1&ofs=0&req0_v=0', (res) => res.socket.end()
@post "/channel/bind?VER=8&RID=1001&SID=#{@session.id}&AID=0", 'count=2&ofs=0&req0_v=0&req1_v=1', (res) =>
res.socket.end()
soon ->
assert.deepEqual maps, [{v:0}, {v:1}]
done()
# With each request to the server, the client tells the server what array it saw last through the AID= parameter.
#
# If a client sends a subsequent backchannel request with an old AID= set, that means the client never saw the arrays
# the server has previously sent. So, the server should resend them.
test 'The server resends lost arrays if the client asks for them', (done) -> @connect ->
process.nextTick =>
@session.send [1,2,3]
@session.send [4,5,6]
@get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=0&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[1, [1,2,3]], [2, [4,5,6]]]
# We'll resend that request, pretending that the client never saw the second array sent (`[4,5,6]`)
req = @get "/channel/bind?VER=8&RID=rpc&SID=#{@session.id}&AID=1&TYPE=xmlhttp&CI=0", (res) =>
readLengthPrefixedJSON res, (data) =>
assert.deepEqual data, [[2, [4,5,6]]]
# We don't need to abort the first connection because the server should close it.
req.abort()
done()
# If you sleep your laptop or something, by the time you open it again the server could have timed out your session
# so your session ID is invalid. This will also happen if the server gets restarted or something like that.
#
# The server should respond to any query requesting a nonexistant session ID with 400 and put 'Unknown SID'
# somewhere in the message. (Actually, the BC client test is `indexOf('Unknown SID') > 0` so there has to be something
# before that text in the message or indexOf will return 0.
#
# The google servers also set Unknown SID as the http status code, which is kinda neat. I can't check for that.
suite 'If a client sends an invalid SID in a request, the server responds with 400 Unknown SID', ->
testResponse = (done) -> (res) ->
assert.strictEqual res.statusCode, 400
buffer res, (data) ->
assert.ok data.indexOf('U