lib/api/webSockets.js
/* istanbul ignore file */
/**
* WebSockets client module.
*/
const WS = require('ws');
const {v1: uuid} = require('uuid');
const {path} = require('ramda');
const Raven = require('raven');
const {fetch, setUserAgent} = require('../utils/fetch');
const SuitestError = require('../utils/SuitestError');
const texts = require('../texts');
const {handleProgress} = require('../utils/progressHandler');
const {getInfoErrorMessage} = require('../utils/socketErrorMessages');
const {translateNotStartedReason} = require('../utils/translateResults');
const logLevels = require('../../lib/constants/logLevels');
/**
* @description print log message that comes from BE side with proper log level
* @param {import('../../suitest').logger} logger - logger object from SUITEST_API
* @param {{type: 'log', subtype: 'info' | 'warn' | 'error', data: string[]}} content - BE message
*/
const printBackendLogMessage = (logger, content) => {
const loggerMethodName = content.subtype in logger ? content.subtype : 'log';
const message = content.data.join('\n') + '\n';
logger[loggerMethodName](message);
};
const webSocketsFactory = (self) => {
const {config, logger} = self;
let ws = null,
requestPromises = {},
connectionPromise = null,
rawDataMessageId = null;
/**
* Connect to ws server
*/
function connect(connectionOps = {}) {
disconnect();
logger.debug('Initializing websocket connection with options:', connectionOps);
if (!connectionOps.headers) {
connectionOps.headers = {};
}
setUserAgent(connectionOps.headers);
ws = new WS(config.wsUrl, connectionOps);
ws.on('message', msg => {
let obj;
try {
obj = JSON.parse(msg);
} catch (e) {
// ignore
}
const content = path(['content'], obj) || {};
if (content.type === 'requestLog' && self.listenerCount('networkLog')) {
const networkLog = JSON.parse(JSON.stringify(content.logItem));
networkLog.type = 'networkLog';
delete networkLog.lineId;
delete networkLog.finalPart;
self.emit('networkLog', networkLog);
} else if (
content.type === 'console' &&
content.data[0] !== 'SUITESTIFY: domainMapping'
) {
const {args: data, method: level} = logger.getAppOutput(content.subtype, content.data) || {};
self.emit('consoleLog', {data, level, type: 'consoleLog'});
}
if (content.type === 'executorStopped') {
// Notify user about executor being stopped
throw new SuitestError(texts.executorStopped());
}// request logs come in with huge amounts of data that
// pollutes the log - get rid of that.
// TODO it would be great to display this data in other then debug mode.
if (content.type === 'requestLog') {
const logItem = obj.content.logItem;
const url = logItem.request? logItem.request.uri : '';
const requestId = logItem.requestId;
const type = url? 'Request start' : 'Request end';
const requestBody = logItem.request && logItem.request.requestBody;
const responseBody = logItem.response && logItem.response.responseBody;
if (config.logLevel === logLevels.debug) {
return logger.debug(
'Incoming request log (details omitted)\n' +
`ItemId: ${requestId}\n` +
`Type: ${type}\n` +
(url ? `URL: ${url}` : '') +
(requestBody ? 'RequestBody: [body]' : '') +
(responseBody ? 'ResponseBody: [body]' : '') +
'\n',
);
} else if (config.logLevel === logLevels.silly) {
return logger.silly(
'Incoming request log\n' +
`ItemId: ${requestId}\n` +
`Type: ${type}\n` +
(url ? `URL: ${url}` : '') +
(requestBody ? `RequestBody: ${requestBody}` : '') +
(responseBody ? `ResponseBody: ${responseBody}` : '') +
'\n',
);
}
}
const isConsole = content.type === 'console';
const consoleData = isConsole && content.data;
// display suitestify mapping message
if (consoleData && consoleData[0] === 'SUITESTIFY: domainMapping') {
return logger.log(content.data.join('\n'));
}
if (content.type === 'log') {
printBackendLogMessage(logger, content);
}
// display application logs
if (isConsole) {
return logger.appOutput(content.subtype, content.data, content.timestamp);
}
const bufferReceived = Buffer.isBuffer(msg);
const message = bufferReceived ? msg : JSON.parse(msg);
if (!bufferReceived) {
logger.debug('Received message:', msg);
} else {
logger.debug('Received message: [raw data]');
}
const screenshotData = (buffer, messageId, result = 'success') => ({
messageId,
content: {
buffer,
result,
},
});
// receiving Buffer for previous ws message related to takeScreenshot
if (bufferReceived) {
const data = screenshotData(message, rawDataMessageId);
rawDataMessageId = null;
handleResponse(data);
} else if (
path([message.messageId, 'contentType'])(requestPromises) === 'takeScreenshot' &&
message.content.result === 'success'
) {
// if isRaw true - next ws message will contains screenshot as binary data
if (message.content.isRaw) {
rawDataMessageId = message.messageId;
} else if (typeof message.content.url === 'string') {
// if isRaw false and url provided - fetch data from url
fetch(message.content.url)
.then(res => res.buffer())
.then((buffer) => {
handleResponse(screenshotData(buffer, message.messageId));
}).catch(() => {
handleResponse({
messageId: message.messageId,
content: {
result: 'error',
},
});
});
}
} else if (
path([message.messageId, 'contentType'])(requestPromises) === 'startRecording' &&
message.content.result === 'error'
) {
// Allowing result: 'error' for startRecording command and not interrupting test flow
const messageId = message.messageId;
const res = message.content.response || message.content;
const req = requestPromises[messageId];
const contentMessage = res.error ? res.error : texts.unknownError();
logger.error(getInfoErrorMessage(contentMessage, '', res, ''));
req.resolve(res);
delete requestPromises[messageId];
} else {
handleResponse(message);
}
});
ws.on('open', () => {
logger.debug('Websocket connected.');
connectionPromise.resolve();
connectionPromise = null;
});
ws.on('close', code => {
logger.debug('Closing websocket connection: ' + code);
disconnect();
});
ws.on('error', error => {
logger.debug('Got websocket error:', error);
connectionPromise && connectionPromise.reject(
new SuitestError(texts.wsNotConnected() + (error ? ` ${error}.` : ''), SuitestError.WS_ERROR));
disconnect();
});
return new Promise((resolve, reject) => {
connectionPromise = {
resolve,
reject,
};
});
}
/**
* Handle ws response, resolve promise from requestPromises map by response messageId
* @param {*} msg
*/
function handleResponse(msg) {
const messageId = msg.messageId;
const res = msg.content.response || msg.content;
const req = requestPromises[messageId];
/* istanbul ignore else */
if (req) {
if (['query', 'testLine', 'eval', 'takeScreenshot'].includes(req.contentType)) {
req.resolve({
...res,
contentType: req.contentType,
}); // resolve chain requests
} else if (res.result === 'success') {
req.resolve(res);
} else {
const message = res.error ? res.error : texts.unknownError();
logger.error(getInfoErrorMessage(message, '', res, ''));
req.reject(new SuitestError(message, SuitestError.UNKNOWN_ERROR));
}
delete requestPromises[messageId];
}
if (res.type === 'notRunningReason') {
try {
logger.log(translateNotStartedReason(res.reason));
} catch (error) {
Raven.captureException(error);
}
}
if (res.type === 'progress') {
handleProgress(logger, res);
}
}
/**
* Handle ws request, add promise to requestPromises map by request messageId
* @param {string} messageId
* @param contentType
* @returns {Promise}
*/
function handleRequest(messageId, contentType) {
return new Promise((resolve, reject) => {
requestPromises[messageId] = {
resolve,
reject,
contentType,
};
});
}
/**
* Send ws message
* @param {Object} content
* @returns {Promise}
*/
function send(content) {
if (ws) {
const messageId = uuid();
const msg = JSON.stringify({
messageId,
content,
});
logger.debug('Sending message:', msg);
ws.send(msg);
return handleRequest(messageId, content.type);
}
return Promise.reject(new SuitestError(texts.wsNotConnected(), SuitestError.WS_ERROR));
}
/**
* Disconnect from ws server
*/
function disconnect() {
if (ws) {
ws.close(1000, 'good bye!');
}
ws = null;
requestPromises = {};
connectionPromise = null;
}
/**
* Check if ws client is connected to server
*/
function isConnected() {
return !!ws;
}
return {
connect,
disconnect,
send,
isConnected,
};
};
module.exports = webSocketsFactory;