@artilleryio/int-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+
5
+ 'use strict';
6
+
7
+ const async = require('async');
8
+ const _ = require('lodash');
9
+ const WebSocket = require('ws');
10
+ const HttpsProxyAgent = require('https-proxy-agent');
11
+ const debug = require('debug')('ws');
12
+ const url = require('url');
13
+ const engineUtil = require('@artilleryio/int-commons').engine_util;
14
+ const template = engineUtil.template;
15
+
16
+ module.exports = WSEngine;
17
+
18
+ function WSEngine(script) {
19
+ this.config = script.config;
20
+ }
21
+
22
+ WSEngine.prototype.createScenario = function (scenarioSpec, ee) {
23
+ const self = this;
24
+ const tasks = _.map(scenarioSpec.flow, function (rs) {
25
+ if (typeof rs.think !== 'undefined') {
26
+ return engineUtil.createThink(
27
+ rs,
28
+ _.get(self.config, 'defaults.think', {})
29
+ );
30
+ }
31
+
32
+ return self.step(rs, ee);
33
+ });
34
+
35
+ return self.compile(tasks, scenarioSpec.flow, ee);
36
+ };
37
+
38
+ function getMessageHandler(context, params, ee, timeout, callback) {
39
+ let done = false;
40
+
41
+ setTimeout(() => {
42
+ if (!done) {
43
+ const err = 'response timeout';
44
+ ee.emit('error', err);
45
+ return callback(err, context);
46
+ }
47
+ }, timeout * 1000);
48
+
49
+ return function messageHandler(event) {
50
+ ee.emit('counter', 'websocket.messages_received', 1);
51
+ ee.emit('rate', 'websocket.receive_rate');
52
+ done = true;
53
+ const { data } = event;
54
+
55
+ debug('WS receive: %s', data);
56
+
57
+ if (!data) {
58
+ return callback(new Error('Empty response from WS server'), context);
59
+ }
60
+
61
+ let fauxResponse;
62
+ try {
63
+ fauxResponse = { body: JSON.parse(data) };
64
+ } catch (err) {
65
+ fauxResponse = { body: event.data };
66
+ }
67
+
68
+ engineUtil.captureOrMatch(
69
+ params,
70
+ fauxResponse,
71
+ context,
72
+ function captured(err, result) {
73
+ if (err) {
74
+ ee.emit('error', err.message || err.code);
75
+ return callback(err, context);
76
+ }
77
+
78
+ const { captures = {}, matches = {} } = result;
79
+
80
+ debug('matches: ', matches);
81
+ debug('captures: ', captures);
82
+
83
+ // match and capture are strict by default:
84
+ const haveFailedMatches = _.some(result.matches, function (v) {
85
+ return !v.success && v.strict !== false;
86
+ });
87
+
88
+ const haveFailedCaptures = _.some(result.captures, function (v) {
89
+ return v.failed;
90
+ });
91
+
92
+ if (haveFailedMatches || haveFailedCaptures) {
93
+ // TODO: Emit the details of each failed capture/match
94
+ return callback(new Error('Failed capture or match'), context);
95
+ }
96
+
97
+ _.each(result.matches, function (v) {
98
+ ee.emit('match', v.success, {
99
+ expected: v.expected,
100
+ got: v.got,
101
+ expression: v.expression,
102
+ strict: v.strict
103
+ });
104
+ });
105
+
106
+ _.each(result.captures, function (v, k) {
107
+ _.set(context.vars, k, v.value);
108
+ });
109
+
110
+ return callback(null, context);
111
+ }
112
+ );
113
+ };
114
+ }
115
+
116
+ WSEngine.prototype.step = function (requestSpec, ee) {
117
+ const self = this;
118
+
119
+ if (requestSpec.loop) {
120
+ const steps = _.map(requestSpec.loop, function (rs) {
121
+ return self.step(rs, ee);
122
+ });
123
+
124
+ return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
125
+ loopValue: requestSpec.loopValue || '$loopCount',
126
+ overValues: requestSpec.over,
127
+ whileTrue: self.config.processor
128
+ ? self.config.processor[requestSpec.whileTrue]
129
+ : undefined
130
+ });
131
+ }
132
+
133
+ if (requestSpec.think) {
134
+ return engineUtil.createThink(
135
+ requestSpec,
136
+ _.get(self.config, 'defaults.think', {})
137
+ );
138
+ }
139
+
140
+ if (requestSpec.function) {
141
+ return function (context, callback) {
142
+ const processFunc = self.config.processor[requestSpec.function];
143
+ if (processFunc) {
144
+ processFunc(context, ee, function () {
145
+ return callback(null, context);
146
+ });
147
+ }
148
+ };
149
+ }
150
+
151
+ if (requestSpec.log) {
152
+ return function (context, callback) {
153
+ console.log(template(requestSpec.log, context));
154
+ return process.nextTick(function () {
155
+ callback(null, context);
156
+ });
157
+ };
158
+ }
159
+
160
+ if (requestSpec.connect) {
161
+ return function (context, callback) {
162
+ return process.nextTick(function () {
163
+ callback(null, context);
164
+ });
165
+ };
166
+ }
167
+
168
+ const f = function (context, callback) {
169
+ const params = requestSpec.wait || requestSpec.send;
170
+
171
+ // match exists on a string, so check match is not a prototype
172
+ let captureOrMatch = _.has(params, 'capture') || _.has(params, 'match');
173
+
174
+ if (captureOrMatch) {
175
+ // only process response if we're capturing
176
+ let timeout =
177
+ self.config.timeout || _.get(self.config, 'ws.timeout') || 10;
178
+ context.ws.onmessage = getMessageHandler(
179
+ context,
180
+ params,
181
+ ee,
182
+ timeout,
183
+ callback
184
+ );
185
+ } else {
186
+ // Reset onmessage to stop steps interfering with each other
187
+ context.ws.onmessage = undefined;
188
+ }
189
+
190
+ // Backwards compatible with previous version of `send` api
191
+ let payload = captureOrMatch ? params.payload : params;
192
+
193
+ if (payload !== undefined) {
194
+ payload = template(payload, context);
195
+ if (typeof payload === 'object') {
196
+ payload = JSON.stringify(payload);
197
+ } else {
198
+ payload = _.toString(payload);
199
+ }
200
+
201
+ ee.emit('counter', 'websocket.messages_sent', 1);
202
+ ee.emit('rate', 'websocket.send_rate');
203
+ debug('WS send: %s', payload);
204
+
205
+ context.ws.send(payload, function (err) {
206
+ if (err) {
207
+ debug(err);
208
+ ee.emit('error', err);
209
+ return callback(err, null);
210
+ }
211
+
212
+ // End step if we're not capturing
213
+ if (!captureOrMatch) {
214
+ return callback(null, context);
215
+ }
216
+ });
217
+ } else if (captureOrMatch) {
218
+ debug('WS wait: %j', params);
219
+ } else {
220
+ // in the end, we could not send anything, so report it and stop
221
+ let err = 'invalid_step';
222
+ debug(err, requestSpec);
223
+ ee.emit('error', err);
224
+ return callback(err, context);
225
+ }
226
+ };
227
+
228
+ return f;
229
+ };
230
+
231
+ function getWsOptions(config) {
232
+ const options = getWsConfig(config);
233
+ const subprotocols = _.get(config, 'ws.subprotocols', []);
234
+ const headers = _.get(config, 'ws.headers', {});
235
+
236
+ const subprotocolHeader = _.find(headers, (value, headerName) => {
237
+ return headerName.toLowerCase() === 'sec-websocket-protocol';
238
+ });
239
+
240
+ if (typeof subprotocolHeader !== 'undefined') {
241
+ // NOTE: subprotocols defined via config.ws.subprotocols take precedence:
242
+ subprotocols.push(...subprotocolHeader.split(',').map((s) => s.trim()));
243
+ }
244
+
245
+ return { options, subprotocols };
246
+ }
247
+
248
+ function getWsInstance(config, scenarioSpec, context, cb) {
249
+ let wsArgs = {
250
+ ...getWsOptions(config),
251
+ target: config.target
252
+ };
253
+ const [{ connect }] = scenarioSpec;
254
+
255
+ if (connect) {
256
+ if (connect.function && config.processor[connect.function]) {
257
+ const processFn = config.processor[connect.function];
258
+
259
+ return processFn(wsArgs, context, (err) => {
260
+ if (err) {
261
+ debug('connect.function', err);
262
+ return cb(err, null);
263
+ }
264
+
265
+ context.wsArgs = wsArgs;
266
+
267
+ return cb(null, context);
268
+ });
269
+ } else if (_.isPlainObject(connect)) {
270
+ const {
271
+ target = config.target,
272
+ headers = _.get(config, 'ws.headers', {}),
273
+ subprotocols = _.get(config, 'ws.subprotocols', []),
274
+ ...instanceConfig
275
+ } = connect;
276
+
277
+ const opt = getWsOptions({
278
+ tls: config.tls,
279
+ ws: { subprotocols, headers, ...instanceConfig }
280
+ });
281
+
282
+ wsArgs = {
283
+ target: template(target, context),
284
+ ...opt
285
+ };
286
+ } else {
287
+ wsArgs.target = template(connect, context);
288
+ }
289
+ }
290
+
291
+ debug('new WebSocket instance:', wsArgs);
292
+
293
+ context.wsArgs = wsArgs;
294
+
295
+ return cb(null, context);
296
+ }
297
+
298
+ WSEngine.prototype.compile = function (tasks, scenarioSpec, ee) {
299
+ const config = this.config;
300
+
301
+ return function scenario(initialContext, callback) {
302
+ function zero(cb) {
303
+ ee.emit('started');
304
+
305
+ getWsInstance(config, scenarioSpec, initialContext, cb);
306
+ }
307
+
308
+ function one(context, cb) {
309
+ const { wsArgs, ...contextWithoutWsArgs } = context;
310
+ const ws = new WebSocket(
311
+ wsArgs.target,
312
+ wsArgs.subprotocols,
313
+ wsArgs.options
314
+ );
315
+
316
+ ws.on('open', function () {
317
+ contextWithoutWsArgs.ws = ws;
318
+
319
+ return cb(null, contextWithoutWsArgs);
320
+ });
321
+
322
+ ws.once('error', function (err) {
323
+ debug(err);
324
+ ee.emit('error', err.message || err.code);
325
+
326
+ return cb(err, {});
327
+ });
328
+ }
329
+
330
+ initialContext._successCount = 0;
331
+
332
+ const steps = _.flatten([zero, one, tasks]);
333
+
334
+ async.waterfall(steps, function scenarioWaterfallCb(err, context) {
335
+ if (err) {
336
+ debug(err);
337
+ }
338
+
339
+ if (context && context.ws) {
340
+ context.ws.close();
341
+ }
342
+
343
+ return callback(err, context);
344
+ });
345
+ };
346
+ };
347
+
348
+ function getWsConfig(config) {
349
+ const tls = config.tls || {};
350
+ const { proxy, ...options } = config.ws || {};
351
+
352
+ if (proxy) {
353
+ const { url: proxyUrl, ...proxyOptions } = proxy;
354
+
355
+ debug('Set proxy: %s, options: %s', proxyUrl, proxyOptions);
356
+
357
+ const agent = new HttpsProxyAgent({
358
+ ...url.parse(proxyUrl),
359
+ ...proxyOptions
360
+ });
361
+
362
+ options.agent = agent;
363
+ }
364
+
365
+ return _.extend(tls, options);
366
+ }
@@ -0,0 +1,14 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+
5
+ function isIdlePhase(phase) {
6
+ return (
7
+ (phase.arrivalRate === 0 && !phase.rampTo) ||
8
+ phase.arrivalCount === 0 ||
9
+ phase.maxVusers === 0 ||
10
+ phase.pause > 0
11
+ );
12
+ }
13
+
14
+ module.exports = isIdlePhase;
package/lib/phases.js ADDED
@@ -0,0 +1,184 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+
5
+ 'use strict';
6
+
7
+ const EventEmitter = require('eventemitter3');
8
+ const async = require('async');
9
+ const _ = require('lodash');
10
+ const isUndefined = _.isUndefined;
11
+ const arrivals = require('arrivals');
12
+ const debug = require('debug')('phases');
13
+ const crypto = require('crypto');
14
+ const driftless = require('driftless');
15
+
16
+ module.exports = phaser;
17
+
18
+ function phaser(phaseSpecs) {
19
+ let ee = new EventEmitter();
20
+
21
+ let tasks = _.map(phaseSpecs, function (spec, i) {
22
+ // Cast defined but non-number (eg: from ENV) values
23
+ [
24
+ 'arrivalRate',
25
+ 'arrivalCount',
26
+ 'pause',
27
+ 'rampTo',
28
+ 'duration',
29
+ 'maxVusers'
30
+ ].forEach(function (k) {
31
+ if (!isUndefined(spec[k]) && typeof spec[k] !== 'number') {
32
+ spec[k] = _.toNumber(spec[k]);
33
+ }
34
+ });
35
+
36
+ if (isUndefined(spec.index)) {
37
+ spec.index = i;
38
+ }
39
+
40
+ if (!isUndefined(spec.arrivalRate) && isUndefined(spec.rampTo)) {
41
+ spec.mode = spec.mode || 'uniform';
42
+ }
43
+
44
+ if (!isUndefined(spec.pause)) {
45
+ return createPause(spec, ee);
46
+ }
47
+
48
+ if (!isUndefined(spec.arrivalCount)) {
49
+ return createArrivalCount(spec, ee);
50
+ }
51
+
52
+ if (!isUndefined(spec.arrivalRate)) {
53
+ // If arrivalRate is zero and it's not a ramp, it's the same as a pause:
54
+ if (spec.arrivalRate === 0 && isUndefined(spec.rampTo)) {
55
+ return createPause(Object.assign(spec, { pause: spec.duration }), ee);
56
+ }
57
+
58
+ // If it's a ramp, create that:
59
+ if (!isUndefined(spec.rampTo)) {
60
+ return createRamp(spec, ee);
61
+ }
62
+
63
+ // Otherwise it's a plain arrival phase:
64
+ return createArrivalRate(spec, ee);
65
+ }
66
+
67
+ console.log('Unknown phase spec\n%j\nThis should not happen', spec);
68
+ });
69
+
70
+ ee.run = function () {
71
+ async.series(tasks, function (err) {
72
+ if (err) {
73
+ debug(err);
74
+ }
75
+
76
+ ee.emit('done');
77
+ });
78
+ };
79
+
80
+ return ee;
81
+ }
82
+
83
+ function createPause(spec, ee) {
84
+ const duration = spec.pause * 1000;
85
+ const task = function (callback) {
86
+ ee.emit('phaseStarted', spec);
87
+ setTimeout(function () {
88
+ ee.emit('phaseCompleted', spec);
89
+ return callback(null);
90
+ }, duration);
91
+ };
92
+ return task;
93
+ }
94
+
95
+ function createRamp(spec, ee) {
96
+ const duration = spec.duration || 1;
97
+ const arrivalRate = spec.arrivalRate;
98
+ const rampTo = spec.rampTo;
99
+
100
+ const tick = 1000 / Math.max(arrivalRate, rampTo); // smallest tick
101
+ const difference = rampTo - arrivalRate;
102
+ const periods = duration * 1000 / tick;
103
+
104
+ function arrivalProbability(currentStep) {
105
+ // linear function ax + b
106
+ // normalized to 0 <= f(t) <= 1
107
+ // Anything under function value should be an arrival
108
+
109
+ let t = currentStep * tick / 1000;
110
+ return ((difference / duration) * t + arrivalRate) / Math.max(rampTo, arrivalRate);
111
+ };
112
+
113
+ let probabilities = Array.from({length: periods}, () => Math.random());
114
+
115
+ debug(
116
+ `rampTo: tick = ${tick}; difference = ${difference}; rampTo = ${rampTo}; arrivalRate = ${arrivalRate}; periods = ${periods}`
117
+ );
118
+
119
+ return function rampTask(callback) {
120
+ ee.emit('phaseStarted', spec);
121
+ let currentStep = 1;
122
+ const timer = driftless.setDriftlessInterval(function maybeArrival() {
123
+ if (currentStep <= periods) {
124
+ let arrivalBreakpoint = arrivalProbability(currentStep);
125
+ let roll = probabilities[currentStep-1];
126
+ debug(`roll:${roll} <= breakpoint:${arrivalBreakpoint}`);
127
+ if (roll <= arrivalBreakpoint) {
128
+ ee.emit('arrival', spec);
129
+ }
130
+
131
+ currentStep++;
132
+ } else {
133
+ driftless.clearDriftless(timer);
134
+ ee.emit('phaseCompleted', spec);
135
+
136
+ return callback(null);
137
+ }
138
+ }, tick);
139
+ };
140
+ }
141
+
142
+ function createArrivalCount(spec, ee) {
143
+ const task = function (callback) {
144
+ ee.emit('phaseStarted', spec);
145
+ const duration = spec.duration * 1000;
146
+
147
+ if (spec.arrivalCount > 0) {
148
+ const interval = duration / spec.arrivalCount;
149
+ const p = arrivals.uniform.process(interval, duration);
150
+ p.on('arrival', function () {
151
+ ee.emit('arrival', spec);
152
+ });
153
+ p.on('finished', function () {
154
+ ee.emit('phaseCompleted', spec);
155
+ return callback(null);
156
+ });
157
+ p.start();
158
+ } else {
159
+ return callback(null);
160
+ }
161
+ };
162
+
163
+ return task;
164
+ }
165
+
166
+ function createArrivalRate(spec, ee) {
167
+ const task = function (callback) {
168
+ ee.emit('phaseStarted', spec);
169
+ const ar = 1000 / spec.arrivalRate;
170
+ const duration = spec.duration * 1000;
171
+ debug('creating a %s process for arrivalRate', spec.mode);
172
+ const p = arrivals[spec.mode].process(ar, duration);
173
+ p.on('arrival', function () {
174
+ ee.emit('arrival', spec);
175
+ });
176
+ p.on('finished', function () {
177
+ ee.emit('phaseCompleted', spec);
178
+ return callback(null);
179
+ });
180
+ p.start();
181
+ };
182
+
183
+ return task;
184
+ }
package/lib/readers.js ADDED
@@ -0,0 +1,65 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
+
5
+ 'use strict';
6
+
7
+ const _ = require('lodash');
8
+
9
+ module.exports = createReader;
10
+
11
+ function createReader(order, spec) {
12
+ if (order === 'sequence') {
13
+ return createSequencedReader();
14
+ } else if (typeof order === 'undefined' && (typeof spec?.name !== 'undefined') && spec?.loadAll === true) {
15
+ return createEverythingReader(spec);
16
+ } else { // random
17
+ return createRandomReader();
18
+ }
19
+ }
20
+
21
+ function createSequencedReader() {
22
+ let i = 0;
23
+ return function (data) {
24
+ let result = data[i];
25
+ if (i < data.length - 1) {
26
+ i++;
27
+ } else {
28
+ i = 0;
29
+ }
30
+ return result;
31
+ };
32
+ }
33
+
34
+ function createEverythingReader(spec) {
35
+ let parsedData;
36
+
37
+ return function (data) {
38
+ if (!parsedData) {
39
+ parsedData = [];
40
+
41
+ // Parse the row into an object based on the fields spec
42
+ if (spec.fields?.length > 0) {
43
+ for (const row of data) {
44
+ let o = {};
45
+ for(let i = 0; i < spec.fields.length; i++) {
46
+ const fieldName = spec.fields[i];
47
+ o[fieldName] = row[i];
48
+ }
49
+ parsedData.push(o);
50
+ }
51
+ } else {
52
+ // Otherwise just return the array of rows
53
+ parsedData = data;
54
+ }
55
+ }
56
+
57
+ return parsedData;
58
+ };
59
+ }
60
+
61
+ function createRandomReader() {
62
+ return function (data) {
63
+ return data[Math.max(0, _.random(0, data.length - 1))];
64
+ };
65
+ }