Commit db1ebae5 authored by Caleb C. Sander's avatar Caleb C. Sander
Browse files

Initial commit

parents
No related merge requests found
Showing with 552 additions and 0 deletions
+552 -0
node_modules
# Project 5 - Chat server
## Spec
The spec for this project can be found at [`specs/chat/server.md`](https://gitlab.caltech.edu/cs11-async/documents/blob/master/specs/chat/server.md).
## Testing infrastructure
Tests are provided in `tests/test.js`.
They try sending different sequences of WebSocket messages to your server and check that the right messages are received.
To run them, you need to run `npm install` once and then `npm test` each time you want to run the tests.
{
"name": "@cs11-async/chat-server",
"version": "1.0.0",
"description": "CS 11 Asynchronous Programming - Chat server",
"scripts": {
"test": "ava --verbose tests/test.js"
},
"repository": {
"type": "git",
"url": "git@gitlab.caltech.edu:cs11-async/chat-server.git"
},
"license": "MIT",
"devDependencies": {
"ava": "^3.5.0"
}
}
const {spawn} = require('child_process')
const test = require('ava')
const {runTest} = require('./util')
process.chdir(__dirname)
const SERVER_PROCESS_DELAY = 100 // ms
let port = 8000
const TESTS = new Map()
.set('basic', {
port: port++,
start(sockets) {
sockets.connect('alice')
},
expected: new Map()
.set('alice', [
{
message: {type: 'users', users: ['alice']},
action(sockets) {
sockets.connect('bob')
}
},
{
message: {type: 'new-user', user: 'bob'},
action(sockets) {
sockets.send('alice', {
type: 'message',
message: {id: '1', user: 'bob', content: 'hi'}
})
}
},
{message: {type: 'read', id: '1'}},
{
message: {type: 'message', message: {id: '2', user: 'bob', content: 'hi back'}},
action(sockets) {
sockets.send('alice', {type: 'read', id: '2'})
}
}
])
.set('bob', [
{message: {type: 'users', users: ['alice', 'bob']}},
{
message: {type: 'message', message: {id: '1', user: 'alice', content: 'hi'}},
action(sockets) {
sockets.send('bob', {type: 'read', id: '1'})
sockets.send('bob', {
type: 'message',
message: {id: '2', user: 'alice', content: 'hi back'}
})
}
},
{message: {type: 'read', id: '2'}}
])
})
.set('logout', {
port: port++,
start(sockets) {
sockets.connect('alice')
},
expected: new Map()
.set('alice', [
{
message: {type: 'users', users: ['alice']},
action(sockets) {
sockets.connect('bob')
}
},
{message: {type: 'new-user', user: 'bob'}},
{
message: {
type: 'message',
message: {id: 'while-online', user: 'bob', content: 'lorem'}
},
action(sockets) {
sockets.disconnect('alice')
setTimeout(() => {
sockets.send('bob', {
type: 'message',
message: {id: 'while-offline', user: 'alice', content: 'ipsum'}
})
setTimeout(() => sockets.connect('alice'), 100)
}, 100)
}
},
// When alice logs in again, she should receive users and the 2 unread messages
{message: {type: 'users', users: ['alice', 'bob']}},
{
message: {
type: 'message',
message: {id: 'while-online', user: 'bob', content: 'lorem'}
}
},
{
message: {
type: 'message',
message: {id: 'while-offline', user: 'bob', content: 'ipsum'}
},
action(sockets) {
sockets.send('alice', {type: 'read', id: 'while-offline'})
sockets.disconnect('alice')
}
},
// When alice logs in again, she should receive users and the 2 unread messages
{message: {type: 'users', users: ['alice', 'bob']}},
{
message: {
type: 'message',
message: {id: 'while-online', user: 'bob', content: 'lorem'}
}
},
{
message: {
type: 'message',
message: {id: 'while-offline2', user: 'bob', content: 'dolor'}
},
action(sockets) {
sockets.send('alice', {type: 'read', id: 'while-offline2'})
sockets.send('alice', {type: 'read', id: 'while-online'})
sockets.disconnect('alice')
setTimeout(() => {
sockets.connect('alice')
sockets.connect('bob')
}, 100)
}
},
// When alice logs in again, she should have no unread messages
{message: {type: 'users', users: ['alice', 'bob']}}
])
.set('bob', [
{
message: {type: 'users', users: ['alice', 'bob']},
action(sockets) {
sockets.send('bob', {
type: 'message',
message: {id: 'while-online', user: 'alice', content: 'lorem'}
})
}
},
{
message: {type: 'read', id: 'while-offline'},
action(sockets) {
sockets.send('bob', {
type: 'message',
message: {id: 'while-offline2', user: 'alice', content: 'dolor'}
})
sockets.disconnect('bob')
setTimeout(() => sockets.connect('alice'), 100)
}
},
// When bob logs in again, he should not receive any read receipts
{message: {type: 'users', users: ['alice', 'bob']}}
])
})
.set('send to self', {
port: port++,
start(sockets) {
sockets.connect('eve')
},
expected: new Map()
.set('chuck', [
{
message: {type: 'users', users: ['chuck', 'dan', 'eve']},
action(sockets) {
sockets.send('chuck', {
type: 'message',
message: {id: 'kcuhc1', user: 'chuck', content: 'fc1c74082ec69f1cdb463e7b9c6319e5'}
})
sockets.send('chuck', {
type: 'message',
message: {id: 'kcuhc2', user: 'chuck', content: '457194698dcb4a719a6894dd935a7b5a'}
})
sockets.send('chuck', {
type: 'message',
message: {id: 'kcuhc3', user: 'chuck', content: '71eb2a9ff97c0a45bfd8527c26daf07f'}
})
sockets.connect('dan')
}
},
{message: {
type: 'message',
message: {id: 'kcuhc1', user: 'chuck', content: 'fc1c74082ec69f1cdb463e7b9c6319e5'}
}},
{
message: {
type: 'message',
message: {id: 'kcuhc2', user: 'chuck', content: '457194698dcb4a719a6894dd935a7b5a'}
},
action(sockets) {
sockets.send('chuck', {type: 'read', id: 'kcuhc2'})
}
},
{
message: {
type: 'message',
message: {id: 'kcuhc3', user: 'chuck', content: '71eb2a9ff97c0a45bfd8527c26daf07f'}
}
},
{
message: {type: 'read', id: 'kcuhc2'},
action(sockets) {
sockets.disconnect('chuck')
setTimeout(() => sockets.connect('chuck'), 100)
}
},
// Chuck logs in again; 2 unread messages
{message: {type: 'users', users: ['chuck', 'dan', 'eve']}},
{message: {
type: 'message',
message: {id: 'kcuhc1', user: 'chuck', content: 'fc1c74082ec69f1cdb463e7b9c6319e5'}
}},
{message: {
type: 'message',
message: {id: 'kcuhc3', user: 'chuck', content: '71eb2a9ff97c0a45bfd8527c26daf07f'}
}}
])
.set('dan', [
{
message: {type: 'users', users: ['dan', 'eve']},
action(sockets) {
sockets.send('dan', {
type: 'message',
message: {id: 'nad', user: 'dan', content: '8abc6f71ba4546ed578bf11808ebf7d6'}
})
}
},
{
message: {
type: 'message',
message: {id: 'nad', user: 'dan', content: '8abc6f71ba4546ed578bf11808ebf7d6'}
},
action(sockets) {
sockets.disconnect('dan')
setTimeout(() => {
sockets.connect('eve')
setTimeout(() => sockets.connect('chuck'), 100)
}, 100)
}
},
// Dan logs in again; 1 unread message
{message: {type: 'users', users: ['chuck', 'dan', 'eve']}},
{
message: {
type: 'message',
message: {id: 'nad', user: 'dan', content: '8abc6f71ba4546ed578bf11808ebf7d6'}
},
action(sockets) {
sockets.disconnect('dan')
setTimeout(() => sockets.connect('dan'), 100)
}
},
// Dan logs in again; still 1 unread message
{message: {type: 'users', users: ['chuck', 'dan', 'eve']}},
{
message: {
type: 'message',
message: {id: 'nad', user: 'dan', content: '8abc6f71ba4546ed578bf11808ebf7d6'}
},
action(sockets) {
sockets.send('dan', {type: 'read', id: 'nad'})
}
},
{
message: {type: 'read', id: 'nad'},
action(sockets) {
sockets.disconnect('dan')
setTimeout(() => sockets.connect('dan'), 100)
}
},
// Dan logs in again; no unread messages
{message: {type: 'users', users: ['chuck', 'dan', 'eve']}}
])
.set('eve', [
{
message: {type: 'users', users: ['eve']},
action(sockets) {
sockets.connect('dan')
}
},
{
message: {type: 'new-user', user: 'dan'},
action(sockets) {
sockets.send('eve', {
type: 'message',
message: {id: 'eve', user: 'eve', content: '2a43e533e43879c4bb7b0a027ead3c54'}
})
}
},
{
message: {
type: 'message',
message: {id: 'eve', user: 'eve', content: '2a43e533e43879c4bb7b0a027ead3c54'}
},
action(sockets) {
sockets.send('eve', {type: 'read', id: 'eve'})
}
},
{
message: {type: 'read', id: 'eve'},
action(sockets) {
sockets.disconnect('eve')
}
},
// Eve logs in again; no unread messages
{message: {type: 'users', users: ['dan', 'eve']}},
{
message: {type: 'new-user', user: 'chuck'},
action(sockets) {
sockets.disconnect('eve')
setTimeout(() => sockets.connect('eve'))
}
},
// Eve logs in again; still no unread messages
{message: {type: 'users', users: ['chuck', 'dan', 'eve']}},
])
})
.set('multiple connections', {
port: port++,
start(sockets) {
sockets.connect('faythe')
},
expected: new Map()
.set('faythe', [
{
message: {type: 'users', users: ['faythe']},
action(sockets) {
sockets.connect('grace')
}
},
{
message: {type: 'new-user', user: 'grace'},
action(sockets) {
sockets.send('faythe', {
type: 'message',
message: {id: 'one', user: 'grace', content: 'first'}
})
}
},
{message: {type: 'read', id: 'one'}},
{
message: {
type: 'message',
message: {user: 'grace', content: "i'm back", id: 'response'}
},
action(sockets) {
// Change field order to make sure JSON parsing is being used
sockets.send('faythe', {id: 'response', type: 'read'})
}
}
])
.set('grace', [
{message: {type: 'users', users: ['faythe', 'grace']}},
{
message: {
type: 'message',
message: {id: 'one', user: 'faythe', content: 'first'}
},
action(sockets) {
sockets.connect('grace')
}
},
// Second connection
{
message: {type: 'users', users: ['faythe', 'grace']},
action(sockets) {
sockets.send('faythe', {
type: 'message',
message: {id: 'two', user: 'grace', content: 'second'}
})
}
},
{message: {
type: 'message',
message: {id: 'one', user: 'faythe', content: 'first'}
}},
{
message: {
type: 'message',
message: {id: 'two', user: 'faythe', content: 'second'}
},
action(sockets) {
sockets.connect('grace')
}
},
// Third connection
{
message: {type: 'users', users: ['faythe', 'grace']},
action(sockets) {
sockets.send('faythe', {
type: 'message',
message: {id: 'three', user: 'grace', content: 'third'}
})
}
},
{message: {
type: 'message',
message: {id: 'one', user: 'faythe', content: 'first'}
}},
{message: {
type: 'message',
message: {id: 'two', user: 'faythe', content: 'second'}
}},
{
message: {
type: 'message',
message: {id: 'three', user: 'faythe', content: 'third'}
},
action(sockets) {
sockets.send('grace', {type: 'read', id: 'one'})
sockets.send('grace', {
// Change field order to make sure JSON parsing is being used
message: {user: 'faythe', content: "i'm back", id: 'response'},
type: 'message'
})
}
},
{message: {type: 'read', id: 'response'}}
])
})
const servers = new Map()
test.beforeEach.cb(t => {
const title = t.title.replace('beforeEach hook for ', '')
const {port} = TESTS.get(title)
servers.set(title,
spawn('node', ['../server.js', `${port}`], {stdio: 'inherit'})
.on('error', t.end)
)
setTimeout(t.end, SERVER_PROCESS_DELAY)
})
for (const [name, chatTest] of TESTS) test.cb(name, t => runTest(t, chatTest))
test.afterEach.always.cb(t => {
const title = t.title.replace('afterEach.always hook for ', '')
setTimeout(() => {
servers.get(title).kill()
t.end()
}, SERVER_PROCESS_DELAY)
})
const WebSocket = require('ws')
const URL = 'ws://localhost'
const TEST_END_DELAY = 100 // ms
const TEST_TIMEOUT = 5000 // ms
class WebSocketManager {
constructor(url, messageHandler) {
this.url = url
this.messageHandler = messageHandler
this.sockets = new Map()
}
connect(user) {
this.sockets.set(user, new WebSocket(this.url)
.on('open', () => this.send(user, {type: 'login', user}))
.on('message', data => this.messageHandler(null, user, data))
.on('error', this.messageHandler)
)
}
send(sender, message) {
const socket = this.sockets.get(sender)
if (!socket) throw new Error(`No socket for user: "${sender}"`)
socket.send(JSON.stringify(message))
}
disconnect(user) {
const socket = this.sockets.get(user)
if (!socket) throw new Error(`No socket for user: "${user}"`)
socket.close()
this.sockets.delete(user)
}
}
function runTest(t, {port, start, expected}) {
const userMessageIndices = new Map()
let messagesLeft = 0
for (const [user, messages] of expected) {
userMessageIndices.set(user, 0)
messagesLeft += messages.length
}
t.plan(messagesLeft)
const sockets = new WebSocketManager(`${URL}:${port}`, (err, to, data) => {
try {
if (err) throw err
const messages = expected.get(to)
const messageIndex = userMessageIndices.get(to)
if (messages === undefined || messageIndex >= messages.length) {
throw new Error(`Unexpected message to ${to}:\n${data}`)
}
const {message, action} = messages[messageIndex]
userMessageIndices.set(to, messageIndex + 1)
if (typeof data !== 'string') throw new Error(`Received binary message:\n${data}`)
const actualMessage = JSON.parse(data)
if (actualMessage.type === 'users') actualMessage.users.sort()
t.deepEqual(actualMessage, message, 'Unexpected message')
if (action) action(sockets)
if (!--messagesLeft) {
clearTimeout(unreceivedTimeout)
setTimeout(t.end, TEST_END_DELAY)
}
}
catch (e) { t.end(e) }
})
start(sockets)
const unreceivedTimeout = setTimeout(() => {
for (const [user, messages] of expected) {
const unreceivedMessages = messages.slice(userMessageIndices.get(user))
if (unreceivedMessages.length) {
t.end(new Error(`Unreceived messages for ${user}:\n${
unreceivedMessages
.map(({message}) => JSON.stringify(message))
.join('\n')
}`))
}
}
}, TEST_TIMEOUT)
}
exports.runTest = runTest
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment