Last active
October 2, 2018 11:44
-
-
Save 8lane/f0605582c3401c6b69e8080c2eb09c77 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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) | |
]) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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: '[email protected]' }, | |
Name: { value: 'tom' }, | |
} | |
} | |
const message = Messages.requestChat({ | |
attributes: ['InteractionType.General'], | |
requestTranscript: true, | |
email: '[email protected]', | |
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: '[email protected]' }, | |
// 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