Skip to content

Instantly share code, notes, and snippets.

@8lane
Last active October 2, 2018 11:44
Show Gist options
  • Save 8lane/f0605582c3401c6b69e8080c2eb09c77 to your computer and use it in GitHub Desktop.
Save 8lane/f0605582c3401c6b69e8080c2eb09c77 to your computer and use it in GitHub Desktop.
/* eslint-disable no-param-reassign */
import { eventChannel, delay } from 'redux-saga'
import { all, take, call, put, race, select, takeEvery } from 'redux-saga/effects'
import { Messages } from '../Socket'
import { ControlsCreators, SocketCreators } from '../../actions'
import { formatAttributesToArray } from '../Api/helpers'
import config from '../Config'
/**
* Saga eventChannel factory to handle socket events
* @param {object} WebSocket instance from the watcher
*/
export function watchMessages(socket) {
return eventChannel((emitter) => {
let socketInterval
/* Socket Opened */
socket.onopen = () => {
emitter(SocketCreators.socketConnectSuccess())
emitter(SocketCreators.sendRequestChat())
socketInterval = setInterval(() => {
emitter(SocketCreators.sendPing())
}, config.pingTimer)
}
/* Socket Message Received */
socket.onmessage = (response) => {
const result = JSON.parse(response.data)
try {
if (result.type === config.messageTypeNotification) {
emitter(SocketCreators[result.body.method](result.body))
} else if (result.type === config.messageTypeError) {
emitter(SocketCreators.socketErrorNotification(result.body))
} else if (result.type === config.messageTypeAck) {
// confirmation customer message is received
} else if (result.type === config.messageTypeNewChatAck) {
console.log('socket messageTypeNewChatAck...')
} else {
throw new Error(`Unknown message type ${result.body}`)
}
} catch (e) {
console.log('error handling socket response', e)
emitter(SocketCreators.socketErrorNotification(e))
}
}
/* Socket Closed */
socket.onclose = (evt) => emitter(SocketCreators.handleWebSocketClose(evt))
/* Socket Error */
socket.onerror = (err) => {
emitter(SocketCreators.socketConnectFailure(err))
console.error('socket error', err)
}
/* eventChannel factory must return a function for when the channel closes */
return () => {
clearInterval(socketInterval)
socket.close()
}
})
}
/**
* Generator to handle closing of the WebSocket
* Attempts automatic reconnects if abnormal close e.g.
* - socket closed having previously connected & not explicity asked to close
* - evt code is anything but 1000 (normal close) or 1005 (no status received)
*/
export function* handleWebSocketClose(evt) {
console.info('socket close event: ', evt)
const { Socket: { previouslyConnected, retryWebSocketConnection } } = yield select()
if (!previouslyConnected || !retryWebSocketConnection || evt.code === 1000 || evt.code === 1005) {
/* normal close - don't reconnect */
yield put(SocketCreators.stopWebSocket({ reconnect: false }))
} else {
/* abnormal close - reconnect */
yield put(SocketCreators.stopWebSocket({ reconnect: true }))
}
}
/**
* Generator to create an initial requestChat/renewChat object
* & then dispatch a sendSocketMessage action with created object
*/
export function* sendRequestChat() {
const {
Api,
Forms: {
EmailAddress: { value: email },
Name: { value: name }
}
} = yield select()
const attributes = formatAttributesToArray(Api.attributes)
const message = Messages.requestChat({
attributes,
email,
contextId: Api.contextId,
name,
requestTranscript: !!email
})
yield put(SocketCreators.sendSocketMessage(message))
}
/**
* Generator to show the messenger once an agent joins
*/
export function* sendPing() {
yield put(SocketCreators.sendSocketMessage(Messages.ping()))
}
/**
* Generator to listen for internal action dispatches
* for sending socket messages
* @param {object} socket instance
*/
export function* internalListener(socket) {
while (true) {
const action = yield take('SEND_SOCKET_MESSAGE')
socket.send(JSON.stringify(action.message))
console.info('sent socket message: ', action.message)
}
}
/**
* Generator to listen for external action dispatches
* from saga eventChannel
* @param {object} eventChannel
*/
export function* externalListener(socketChannel) {
while (true) {
const action = yield take(socketChannel)
if (action.type === 'HANDLE_WEB_SOCKET_CLOSE') {
yield call(handleWebSocketClose, action.event)
}
yield put(action)
}
}
/**
* Generator to handle reconnection of web socket
* Increments number of attempts each iteration until limit is hit
*/
export function* reconnectWebSocket() {
const { Socket: { reconnectAttempts } } = yield select()
yield delay(config.reconnectionTimeout)
if (reconnectAttempts <= config.maxReconnectAttempts) {
yield put(ControlsCreators.setActiveForm('WaitingRoom'))
} else {
yield put(ControlsCreators.toggleDisabled())
yield put(SocketCreators.socketConnectFailure(config.maxReconnectError))
}
}
export function* initSocketSagas() {
yield put(SocketCreators.socketConnectAttempt())
const socket = new WebSocket(config.getWebSocketUrl())
const socketChannel = yield call(watchMessages, socket)
/* bi-directional websocket listeners */
const { cancel } = yield race({
task: [
call(externalListener, socketChannel),
call(internalListener, socket)
],
cancel: take('STOP_WEB_SOCKET')
})
if (cancel) {
socketChannel.close()
const { options: { reconnect } } = cancel
if (reconnect) {
yield put(SocketCreators.reconnectWebSocket())
}
}
}
/**
* Watcher
*/
export default function* rootSaga() {
yield all([
takeEvery('START_WEB_SOCKET', initSocketSagas),
takeEvery('RECONNECT_WEB_SOCKET', reconnectWebSocket),
takeEvery('SEND_PING', sendPing),
takeEvery('SEND_REQUEST_CHAT', sendRequestChat)
])
}
import { eventChannel, delay } from 'redux-saga'
import { take, call, put, race, select } from 'redux-saga/effects'
import { cloneableGenerator } from 'redux-saga/utils'
import { WebSocket } from 'mock-socket'
import rootSaga, {
initSocketSagas,
watchMessages,
internalListener,
externalListener,
sendRequestChat,
sendPing,
newParticipant,
reconnectWebSocket,
handleWebSocketClose
} from './sagas'
import { Creators as SocketCreators, Messages } from '../Socket'
import { Creators as ControlsCreators } from '../../Controls/actions'
jest.mock('redux-saga')
describe('When setting up the web socket', () => {
let generator
let socket
beforeAll(() => {
global.WebSocket = WebSocket
generator = cloneableGenerator(initSocketSagas)();
})
it('dispatch an action to say the socket connection is being attempted', () => {
expect(generator.next().value).toEqual(put(SocketCreators.socketConnectAttempt()))
})
it('should create a saga eventChannel to handle the WebSocket messages', () => {
socket = new WebSocket('wss://chat.argos.co.uk/webchat/socket')
expect(generator.next().value).toEqual(call(watchMessages, socket))
})
it('should create a saga race to handle our external socket events & internal socket actions', () => {
const socketChannel = jest.fn()
expect(generator.next(socketChannel).value).toEqual(race({
task: [
call(externalListener, socketChannel),
call(internalListener, socket)
],
cancel: take('STOP_WEB_SOCKET')
}))
})
})
describe('When closing the web socket', () => {
let generator
let socket
let closeSpy
beforeAll(() => {
global.WebSocket = WebSocket
generator = cloneableGenerator(initSocketSagas)();
closeSpy = jest.fn()
socket = new WebSocket('wss://chat.argos.co.uk/webchat/socket')
// skip some repeated steps from previous test
generator.next()
generator.next()
})
it('should create a saga race to handle our external socket events & internal socket actions', () => {
const socketChannel = { close: closeSpy }
expect(generator.next(socketChannel).value).toEqual(race({
task: [
call(externalListener, socketChannel),
call(internalListener, socket)
],
cancel: take('STOP_WEB_SOCKET')
}))
})
it('should close the event channel and not reconnect', () => {
const cancel = { cancel: { options: { reconnect: false }} }
expect(generator.next(cancel).value).not.toEqual(put(SocketCreators.reconnectWebSocket()))
expect(closeSpy).toHaveBeenCalled()
})
it('should not reconnect the websocket', () => {
expect(generator.next().value).not.toEqual(put(SocketCreators.reconnectWebSocket()))
})
})
describe('When closing the web socket and then reconnecting', () => {
let generator
let socket
let closeSpy
beforeAll(() => {
global.WebSocket = WebSocket
generator = cloneableGenerator(initSocketSagas)();
closeSpy = jest.fn()
socket = new WebSocket('wss://chat.argos.co.uk/webchat/socket')
// skip some repeated steps from previous test
generator.next()
generator.next()
})
it('should create a saga race to handle our external socket events & internal socket actions', () => {
const socketChannel = { close: closeSpy }
expect(generator.next(socketChannel).value).toEqual(race({
task: [
call(externalListener, socketChannel),
call(internalListener, socket)
],
cancel: take('STOP_WEB_SOCKET')
}))
})
it('should close the event channel then reconnect', () => {
const cancel = { cancel: { options: { reconnect: true }} }
expect(generator.next(cancel).value).toEqual(put(SocketCreators.reconnectWebSocket()))
expect(closeSpy).toHaveBeenCalled()
})
})
describe('When listening for external socket events', () => {
let generator
let socketChannel = jest.fn()
beforeAll(() => {
generator = cloneableGenerator(externalListener)(socketChannel);
})
it('should suspend the generator until an action is received from the socket', () => {
expect(generator.next().value).toEqual(take(socketChannel))
})
it('should dispatch a redux action based on the socket event received', () => {
const action = { type: 'IS_TYPING' }
expect(generator.next(action).value).toEqual(put(action))
})
describe('and the event is specifically to handle the closing of the socket', () => {
let action
beforeAll(() => {
generator = cloneableGenerator(externalListener)(socketChannel);
})
it('should suspend the generator until the specific action is received', () => {
expect(generator.next().value).toEqual(take(socketChannel))
})
it('should begin to close the connection', () => {
action = { type: 'HANDLE_WEB_SOCKET_CLOSE', event: 'closeEvt' }
expect(generator.next(action).value).toEqual(call(handleWebSocketClose, 'closeEvt'))
})
it('should dispatch a redux action based on the socket event received', () => {
expect(generator.next().value).toEqual(put(action))
})
})
})
describe('When listening for internal socket push events', () => {
let generator
let sendSpy = jest.fn()
let socket = { send: sendSpy }
beforeAll(() => {
global.console = { info: jest.fn() } /* prevent console log from firing in tests */
generator = cloneableGenerator(internalListener)(socket);
})
it('should suspend the generator until a sendSocketMessage action is received', () => {
expect(generator.next().value).toEqual(take('SEND_SOCKET_MESSAGE'))
})
it('should send the command to the WebSocket instance', () => {
const action = { message: 'hi agent, help me plz' }
expect(generator.next(action).value).toEqual(take('SEND_SOCKET_MESSAGE'))
expect(sendSpy).toHaveBeenCalledWith(JSON.stringify(action.message))
})
})
describe('When requesting a chat', () => {
let generator
describe('for the first time', () => {
beforeAll(() => {
const actionCreator = SocketCreators.sendRequestChat()
generator = cloneableGenerator(sendRequestChat)(actionCreator);
})
it('should grab the email and name and auth/guid from the store', () => {
expect(generator.next().value).toEqual(select())
})
it('should send a socket message with the created message object', () => {
const store = {
Api: { attributes: { InteractionType: 'General' } },
Socket: { authenticationKey: '', guid: '' },
Forms: {
EmailAddress: { value: 'tom@t23ss.com' },
Name: { value: 'tom' },
}
}
const message = Messages.requestChat({
attributes: ['InteractionType.General'],
requestTranscript: true,
email: 'tom@t23ss.com',
name: 'tom'
})
expect(generator.next(store).value).toEqual(put(SocketCreators.sendSocketMessage(message)))
})
describe('and no email has been provided', () => {
beforeAll(() => {
const actionCreator = SocketCreators.sendRequestChat()
generator = cloneableGenerator(sendRequestChat)(actionCreator);
})
it('should not request for a transcript from Avaya', () => {
generator.next() // skip first step
const store = {
Api: { attributes: { InteractionType: 'General' } },
Socket: { authenticationKey: '', guid: '' },
Forms: {
EmailAddress: { value: '' },
Name: { value: 'tom' }
}
}
const message = Messages.requestChat({
attributes: ['InteractionType.General'],
requestTranscript: false,
email: '',
name: 'tom'
})
expect(generator.next(store).value).toEqual(put(SocketCreators.sendSocketMessage(message)))
})
})
})
// xdescribe('and renewing a previous chat', () => {
// beforeAll(() => {
// const actionCreator = SocketCreators.sendRequestChat()
// generator = cloneableGenerator(sendRequestChat)(actionCreator);
// })
// it('should grab the email and name and auth/guid from the store', () => {
// expect(generator.next().value).toEqual(select())
// })
// it('should send a socket message with the created message object', () => {
// const store = {
// Api: { attributes: { InteractionType: 'General' } },
// Socket: { authenticationKey: '123', guid: '456' },
// Forms: {
// EmailAddress: { value: 'tom@t23ss.com' },
// Name: { value: 'tom' }
// }
// }
// const message = Messages.renewChat({ authenticationKey: '123', guid: '456' })
// expect(generator.next(store).value).toEqual(put(SocketCreators.sendSocketMessage(message)))
// })
// describe('and no email has been provided', () => {
// beforeAll(() => {
// const actionCreator = SocketCreators.sendRequestChat()
// generator = cloneableGenerator(sendRequestChat)(actionCreator);
// })
// it('should not request for a transcript from Avaya', () => {
// generator.next() // skip first step
// const message = Messages.renewChat({ authenticationKey: '123', guid: '456' })
// const store = {
// Api: { attributes: { InteractionType: 'General' } },
// Socket: { authenticationKey: '123', guid: '456' },
// Forms: {
// EmailAddress: { value: '' },
// Name: { value: 'tom' },
// }
// }
// expect(generator.next(store).value).toEqual(put(SocketCreators.sendSocketMessage(message)))
// })
// })
// })
})
describe('When reconnecting the web socket', () => {
let generator
describe('and max number of retries has not been hit', () => {
beforeAll(() => {
const actionCreator = SocketCreators.reconnectWebSocket()
generator = cloneableGenerator(reconnectWebSocket)(actionCreator);
})
it('should get the number of retries from the store', () => {
expect(generator.next().value).toEqual(select())
})
it('should wait 3 seconds', () => {
expect(generator.next({ Socket: { reconnectAttempts: 2 }}).value).toEqual(delay(3000))
})
it('should show the waiting room (to reinit EWT and Socket)', () => {
expect(generator.next().value).toEqual(put(ControlsCreators.setActiveForm('WaitingRoom')))
})
})
describe('and max number of retries has been hit', () => {
beforeAll(() => {
const actionCreator = SocketCreators.reconnectWebSocket()
generator = cloneableGenerator(reconnectWebSocket)(actionCreator);
})
it('should get the number of retries from the store', () => {
expect(generator.next().value).toEqual(select())
})
it('should wait 3 seconds', () => {
expect(generator.next({ Socket: { reconnectAttempts: 5 }}).value).toEqual(delay(3000))
})
it('should reenable the controls', () => {
expect(generator.next().value).toEqual(put(ControlsCreators.toggleDisabled()))
})
it('should show an error message', () => {
expect(put(SocketCreators.socketConnectFailure('Max number of reconnect attempted')))
})
})
})
describe('When handling the closing of the WebSocket', () => {
let generator
global.console = { info: jest.fn() } /* prevent console log from firing in test */
// close abnormally (reconnect)
describe('and the socket has already been connected previously or agent/user initiates the close', () => {
beforeAll(() => {
const socketCloseEvent = {}
generator = cloneableGenerator(handleWebSocketClose)(socketCloseEvent);
})
it('should stop the current connection and attempt to start a new one', () => {
const store = { Socket: { previouslyConnected: true, retryWebSocketConnection: true } }
expect(generator.next(store).value).toEqual(select())
expect(generator.next(store).value).toEqual(put(SocketCreators.stopWebSocket({ reconnect: true })))
})
})
// close normally
describe('and socket has not been connected previously', () => {
beforeAll(() => {
const socketCloseEvent = {}
generator = cloneableGenerator(handleWebSocketClose)(socketCloseEvent);
})
it('should stop the web socket normally without reconnecting after', () => {
const store = { Socket: { previouslyConnected: false, retryWebSocketConnection: false } }
expect(generator.next(store).value).toEqual(select())
expect(generator.next(store).value).toEqual(put(SocketCreators.stopWebSocket({ reconnect: false })))
})
})
describe('and the user OR agent has initiated the close', () => {
beforeAll(() => {
const socketCloseEvent = {}
generator = cloneableGenerator(handleWebSocketClose)(socketCloseEvent);
})
it('should stop the web socket normally without reconnecting after', () => {
const store = { Socket: { previouslyConnected: true, retryWebSocketConnection: false } }
expect(generator.next(store).value).toEqual(select())
expect(generator.next(store).value).toEqual(put(SocketCreators.stopWebSocket({ reconnect: false })))
})
})
describe('and socket has closed due to a normal (1000) event code closure', () => {
beforeAll(() => {
const socketCloseEvent = { code: 1000 }
generator = cloneableGenerator(handleWebSocketClose)(socketCloseEvent);
})
it('should stop the web socket normally without reconnecting after', () => {
const store = { Socket: { previouslyConnected: true, retryWebSocketConnection: false } }
expect(generator.next(store).value).toEqual(select())
expect(generator.next(store).value).toEqual(put(SocketCreators.stopWebSocket({ reconnect: false })))
})
})
describe('and socket has closed due to not receiving any status (1005) event code closure', () => {
beforeAll(() => {
const socketCloseEvent = { code: 1005 }
generator = cloneableGenerator(handleWebSocketClose)(socketCloseEvent);
})
it('should stop the web socket normally without reconnecting after', () => {
const store = { Socket: { previouslyConnected: true, retryWebSocketConnection: false } }
expect(generator.next(store).value).toEqual(select())
expect(generator.next(store).value).toEqual(put(SocketCreators.stopWebSocket({ reconnect: false })))
})
})
})
describe('When pinging the socket to see if connected', () => {
let generator
beforeAll(() => {
const actionCreator = SocketCreators.sendPing()
generator = cloneableGenerator(sendPing)(actionCreator);
})
it('should send a socket a message with the correct message body', () => {
const socketMessage = {
authToken: '',
apiVersion: '1.0',
type: 'request',
body: {
method: 'ping',
}
}
expect(generator.next().value).toEqual(put(SocketCreators.sendSocketMessage(socketMessage)))
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment