artillery
Version:
Cloud-scale load testing. https://www.artillery.io
348 lines (347 loc) • 15.4 kB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import async from 'async';
import createDebug from 'debug';
import deepEqual from 'fast-deep-equal';
import _ from 'lodash';
import io from 'socket.io-client';
import sioWildcard from 'socketio-wildcard';
import { engine_util as engineUtil } from "../commons/index.js";
import EngineHttp from "./engine_http.js";
const wildcardPatch = sioWildcard(io.Manager);
const debug = createDebug('socketio');
const template = engineUtil.template;
function markEndTime(ee, _context, startedAt) {
const endedAt = process.hrtime(startedAt);
const delta = endedAt[0] * 1e9 + endedAt[1];
ee.emit('histogram', 'socketio.response_time', delta / 1e6);
}
function isResponseRequired(spec) {
return Boolean(spec.emit && spec.response && (spec.response.channel || spec.response.on));
}
function isAcknowledgeRequired(spec) {
return Boolean(spec.emit && spec.acknowledge);
}
function isValid(data, response) {
if (_.isArray(response.data)) {
//we check if it's an array first (as arrays are objects), and if it's an array, do a deep equality check between both arrays
return deepEqual(data, response.data);
}
if (_.isObject(response.data)) {
//`json` key is added at some point to the response.data object, to use with `captureOrMatch` function
//we should omit it when comparing the response to the data
const expectedResponse = _.omit(response.data, 'json');
const actualResponse = data[data.length - 1]; // if response.data is not an array, we compare it to the last element of the actual response
return deepEqual(actualResponse, expectedResponse);
}
if (_.isString(response.data)) {
const expectedResponse = response.data;
let actualResponse = data[data.length - 1]; // if response.data is not an array, we compare it to the last element of the actual response
// unless the user wants to test against the entire response
if (response.concat) {
actualResponse = data.join('');
}
debug(`checking if string ${expectedResponse} is a partial match for string ${actualResponse}`);
return actualResponse.includes(expectedResponse); //we accept a partial match if it's a string
}
debug(`unexpected data type for response.data: ${typeof response.data}`);
return false;
}
function processResponse(ee, data, response, context, callback) {
// Do we have supplied data to validate?
if (response.data && !isValid(data, response)) {
debug('data is not valid:');
debug(data);
debug(response);
const err = 'data is not valid';
ee.emit('error', err);
return callback(err, context);
}
// If no capture or match specified, then we consider it a success at this point...
if (!response.capture && !response.match) {
return callback(null, context);
}
// Construct the (HTTP) response...
const fauxResponse = { body: JSON.stringify(data) };
// Handle the capture or match clauses...
engineUtil.captureOrMatch(response, fauxResponse, context, (err, result) => {
// Were we unable to invoke captureOrMatch?
if (err) {
debug(data);
ee.emit('error', err);
return callback(err, context);
}
if (result !== null) {
// Do we have any failed matches?
const failedMatches = _.filter(result.matches, (v) => {
return !v.success;
});
// How to handle failed matches?
if (failedMatches.length > 0) {
debug(failedMatches);
// TODO: Should log the details of the match somewhere
ee.emit('error', 'Failed match');
return callback(new Error('Failed match'), context);
}
else {
// Populate the context with captured values
_.each(result.captures, (v, k) => {
context.vars[k] = v.value;
});
}
// Replace the base object context
// Question: Should this be JSON object or String?
context.vars.$ = fauxResponse.body;
// Increment the success count...
context._successCount++;
return callback(null, context);
}
});
}
export default class SocketIoEngine {
constructor(script) {
this.config = script.config;
this.socketioOpts = this.config.socketio || {};
this.httpDelegate = new EngineHttp(script);
}
async init() {
await this.httpDelegate.init();
}
createScenario(scenarioSpec, ee) {
// Adds scenario overridden configuration into the static config
this.socketioOpts = { ...this.socketioOpts, ...scenarioSpec.socketio };
const tasks = _.map(scenarioSpec.flow, (rs) => {
if (typeof rs.think !== 'undefined') {
return engineUtil.createThink(rs, _.get(this.config, 'defaults.think', {}));
}
return this.step(rs, ee);
});
return this.compile(tasks, scenarioSpec.flow, ee);
}
step(requestSpec, ee) {
if (requestSpec.loop) {
const steps = _.map(requestSpec.loop, (rs) => {
if (!rs.emit && !rs.loop) {
return this.httpDelegate.step(rs, ee);
}
return this.step(rs, ee);
});
return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
loopValue: requestSpec.loopValue,
loopElement: requestSpec.loopElement || '$loopElement',
overValues: requestSpec.over,
whileTrue: this.config.processor
? this.config.processor[requestSpec.whileTrue]
: undefined
});
}
const f = (context, callback) => {
// Only process emit requests; delegate the rest to the HTTP engine (or think utility)
if (requestSpec.think) {
return engineUtil.createThink(requestSpec, _.get(this.config, 'defaults.think', {}));
}
if (!requestSpec.emit) {
const delegateFunc = this.httpDelegate.step(requestSpec, ee);
return delegateFunc(context, callback);
}
ee.emit('counter', 'socketio.emit', 1);
ee.emit('rate', 'socketio.emit_rate');
const startedAt = process.hrtime();
const socketio = context.sockets[requestSpec.namespace] || null;
if (!(requestSpec.emit && socketio)) {
debug('invalid arguments');
ee.emit('error', 'invalid arguments');
// TODO: Provide a more helpful message
callback(new Error('socketio: invalid arguments'));
}
const outgoing = requestSpec.emit.channel
? [
template(requestSpec.emit.channel, context),
template(requestSpec.emit.data, context)
]
: Array.from(requestSpec.emit).map((arg) => template(arg, context));
const endCallback = (err, context, needEmit) => {
if (err) {
debug(err);
}
if (isAcknowledgeRequired(requestSpec)) {
const ackCallback = (...args) => {
const response = {
data: template(requestSpec.acknowledge.data || requestSpec.acknowledge.args, context),
capture: template(requestSpec.acknowledge.capture, context),
match: template(requestSpec.acknowledge.match, context)
};
// Make sure data, capture or match has a default json spec for parsing socketio responses
_.each(response, (r) => {
if (_.isPlainObject(r) && !('json' in r)) {
r.json = '$.0'; // Default to the first callback argument
}
});
// Acknowledge data can take up multiple arguments of the emit callback
processResponse(ee, args, response, context, (err) => {
if (!err) {
markEndTime(ee, context, startedAt);
}
return callback(err, context);
});
};
// Acknowledge required so add callback to emit
if (needEmit) {
socketio.emit(...outgoing, ackCallback);
}
else {
ackCallback();
}
}
else {
// No acknowledge data is expected, so emit without a listener
if (needEmit) {
socketio.emit(...outgoing);
}
markEndTime(ee, context, startedAt);
return callback(err, context);
}
}; // endCallback
if (isResponseRequired(requestSpec)) {
const response = {
channel: template(requestSpec.response.channel || requestSpec.response.on, context),
concat: template(requestSpec.response.concat, context),
data: template(requestSpec.response.data || requestSpec.response.args, context),
capture: template(requestSpec.response.capture, context),
match: template(requestSpec.response.match, context)
};
// Listen for the socket.io response on the specified channel
let done = false;
const responseData = [];
socketio.on(response.channel, function receive(...args) {
responseData.push(...args);
if (isValid(responseData, response)) {
done = true;
processResponse(ee, responseData, response, context, (err) => {
if (!err) {
markEndTime(ee, context, startedAt);
}
// Stop listening on the response channel
socketio.off(response.channel);
return endCallback(err, context, false);
});
}
});
// Send the data on the specified socket.io channel
socketio.emit(...outgoing);
// If we don't get a response within the timeout, fire an error
const waitTime = (this.config.timeout || 10) * 1000;
setTimeout(function responseTimeout() {
if (!done) {
if (responseData.length) {
processResponse(ee, responseData, response, context, (err) => {
if (!err) {
markEndTime(ee, context, startedAt);
}
// Stop listening on the response channel
socketio.off(response.channel);
// called
return endCallback(err, context, false);
});
return;
}
const err = 'response timeout';
ee.emit('error', err);
return callback(err, context);
}
}, waitTime);
}
else {
endCallback(null, context, true);
}
};
const preStep = (context, callback) => {
// Set default namespace in emit action
requestSpec.namespace = template(requestSpec.namespace, context) || '';
this.loadContextSocket(requestSpec.namespace, context, (err) => {
if (err) {
debug(err);
ee.emit('error', err.message);
return callback(err, context);
}
return f(context, callback);
});
};
if (requestSpec.emit) {
return preStep;
}
else {
return f;
}
}
loadContextSocket(namespace, context, cb) {
context.sockets = context.sockets || {};
if (!context.sockets[namespace]) {
const target = this.config.target + namespace;
const tls = this.config.tls || {};
const socketioOpts = template(this.socketioOpts, context);
const options = _.extend({}, socketioOpts, // templated
tls);
const socket = io(target, options);
context.sockets[namespace] = socket;
wildcardPatch(socket);
socket.on('*', () => {
context.__receivedMessageCount++;
});
socket.once('connect', () => {
cb(null, socket);
});
socket.once('connect_error', (err) => {
cb(err, null);
});
socket.once('error', (err) => {
cb(err, socket);
});
}
else {
return cb(null, context.sockets[namespace]);
}
}
closeContextSockets(context) {
if (context.sockets && Object.keys(context.sockets).length > 0) {
const namespaces = Object.keys(context.sockets);
namespaces.forEach((namespace) => {
context.sockets[namespace].disconnect();
});
}
}
compile(tasks, scenarioSpec, ee) {
const self = this;
function zero(callback, context) {
context.__receivedMessageCount = 0;
ee.emit('started');
self.loadContextSocket('', context, function done(err) {
if (err) {
ee.emit('error', err);
return callback(err, context);
}
return callback(null, context);
});
}
return function scenario(initialContext, callback) {
initialContext = self.httpDelegate.setInitialContext(initialContext);
initialContext._pendingRequests = _.size(_.reject(scenarioSpec, (rs) => typeof rs.think === 'number'));
const steps = _.flatten([
function z(cb) {
return zero(cb, initialContext);
},
tasks
]);
async.waterfall(steps, function scenarioWaterfallCb(err, context) {
if (err) {
debug(err);
}
if (context) {
self.closeContextSockets(context);
}
return callback(err, context);
});
};
}
}