@artilleryio/int-commons 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.
package/engine_util.js ADDED
@@ -0,0 +1,672 @@
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 debug = require('debug')('engine_util');
9
+ const deepForEach = require('deep-for-each');
10
+ const espree = require('espree');
11
+ const L = require('lodash');
12
+ const vm = require('vm');
13
+ const A = require('async');
14
+ const { JSONPath: jsonpath } = require('jsonpath-plus');
15
+ const cheerio = require('cheerio');
16
+ const jitter = require('./jitter').jitter;
17
+
18
+ let xmlCapture;
19
+ try {
20
+ xmlCapture = require('artillery-xml-capture');
21
+ } catch (e) {
22
+ xmlCapture = null;
23
+ }
24
+
25
+ module.exports = {
26
+ createThink: createThink,
27
+ createLoopWithCount: createLoopWithCount,
28
+ createParallel: createParallel,
29
+ isProbableEnough: isProbableEnough,
30
+ template: template,
31
+ captureOrMatch,
32
+ evil: evil,
33
+ ensurePropertyIsAList: ensurePropertyIsAList,
34
+ _renderVariables: renderVariables
35
+ };
36
+
37
+ function createThink(requestSpec, opts) {
38
+ opts = opts || {};
39
+
40
+ let thinkspec = requestSpec.think;
41
+
42
+ let f = function think(context, callback) {
43
+ let thinktime = parseFloat(template(thinkspec, context)) * 1000;
44
+ if (requestSpec.jitter || opts.jitter) {
45
+ thinktime = jitter(`${thinktime}:${requestSpec.jitter || opts.jitter}`);
46
+ }
47
+ debug(
48
+ 'think %s, %s, %s -> %s',
49
+ requestSpec.think,
50
+ requestSpec.jitter,
51
+ opts.jitter,
52
+ thinktime
53
+ );
54
+ setTimeout(function () {
55
+ callback(null, context);
56
+ }, thinktime);
57
+ };
58
+
59
+ return f;
60
+ }
61
+
62
+ // "count" can be an integer (negative or positive) or a string defining a range
63
+ // like "1-15"
64
+ function createLoopWithCount(count, steps, opts) {
65
+ return function aLoop(context, callback) {
66
+ let count2 = count;
67
+ if (typeof count === 'string') {
68
+ count2 = template(count, context);
69
+ }
70
+
71
+ let from = parseLoopCount(count2).from;
72
+ let to = parseLoopCount(count2).to;
73
+
74
+ let i = from;
75
+ let newContext = context;
76
+ let loopIndexVar = (opts && opts.loopValue) || '$loopCount';
77
+ let loopElementVar = (opts && opts.loopElement) || '$loopElement';
78
+ // Should we stop early because the value of "over" is not an array
79
+ let abortEarly = false;
80
+
81
+ let overValues = null;
82
+ let loopValue = i; // default to the current iteration of the loop, ie same as $loopCount
83
+ if (typeof opts.overValues !== 'undefined') {
84
+ if (opts.overValues && typeof opts.overValues === 'object') {
85
+ overValues = opts.overValues;
86
+ loopValue = overValues[i];
87
+ } else if (opts.overValues && typeof opts.overValues === 'string') {
88
+ overValues = L.get(context.vars, opts.overValues);
89
+ if (L.isArray(overValues)) {
90
+ loopValue = overValues[i];
91
+ } else {
92
+ abortEarly = true;
93
+ }
94
+ }
95
+ }
96
+
97
+ newContext.vars[loopElementVar] = loopValue;
98
+ newContext.vars[loopIndexVar] = i;
99
+
100
+ let shouldContinue = true;
101
+
102
+ A.whilst(
103
+ function test() {
104
+ if (abortEarly) {
105
+ return false;
106
+ }
107
+ if (opts.whileTrue) {
108
+ return shouldContinue;
109
+ }
110
+ if (overValues !== null) {
111
+ return i !== overValues.length;
112
+ } else {
113
+ return i < to || to === -1;
114
+ }
115
+ },
116
+ function repeated(cb) {
117
+ let zero = function (cb2) {
118
+ return cb2(null, newContext);
119
+ };
120
+ let steps2 = L.flatten([zero, steps]);
121
+
122
+ A.waterfall(steps2, function (err, context2) {
123
+ if (err) {
124
+ return cb(err, context2);
125
+ }
126
+ i++;
127
+ newContext = context2;
128
+
129
+ newContext.vars[loopIndexVar]++;
130
+ if (overValues !== null) {
131
+ newContext.vars[loopElementVar] = overValues[i];
132
+ }
133
+
134
+ if (opts.whileTrue) {
135
+ opts.whileTrue(context2, function done(b) {
136
+ shouldContinue = b;
137
+ return cb(err, context2);
138
+ });
139
+ } else {
140
+ return cb(err, context2);
141
+ }
142
+ });
143
+ },
144
+ function (err, finalContext) {
145
+ if (typeof finalContext === 'undefined') {
146
+ // this happens if test() returns false immediately, e.g. with
147
+ // nested loops where one of the inner loops goes over an
148
+ // empty array
149
+ return callback(err, newContext);
150
+ }
151
+ return callback(err, finalContext);
152
+ }
153
+ );
154
+ };
155
+ }
156
+
157
+ function createParallel(steps, opts) {
158
+ let limit = (opts && opts.limitValue) || 100;
159
+
160
+ return function aParallel(context, callback) {
161
+ let newContext = context;
162
+ let newCallback = callback;
163
+
164
+ // Remap the steps array to pass the context into each step.
165
+ let newSteps = L.map(steps, function (step) {
166
+ return function (callback) {
167
+ step(newContext, callback);
168
+ };
169
+ });
170
+
171
+ // Run each of the steps in parallel.
172
+ A.parallelLimit(newSteps, limit, function (err, finalContext) {
173
+ // We don't need to do anything with the array of contexts returned from each step at the moment.
174
+ return newCallback(err, newContext);
175
+ });
176
+ };
177
+ }
178
+
179
+ function isProbableEnough(obj) {
180
+ if (typeof obj.probability === 'undefined') {
181
+ return true;
182
+ }
183
+
184
+ let probability = Number(obj.probability) || 0;
185
+ if (probability > 100) {
186
+ probability = 100;
187
+ }
188
+
189
+ let r = L.random(100);
190
+ return r < probability;
191
+ }
192
+
193
+ function template(o, context, inPlace) {
194
+ let result;
195
+
196
+ if (typeof o === 'undefined') {
197
+ return undefined;
198
+ }
199
+
200
+ if (o && (o.constructor === Object || o.constructor === Array)) {
201
+ if (!inPlace) {
202
+ result = L.cloneDeep(o);
203
+ } else {
204
+ result = o;
205
+ }
206
+ templateObjectOrArray(result, context);
207
+ } else if (typeof o === 'string') {
208
+ if (!/{{/.test(o)) {
209
+ return o;
210
+ }
211
+ const funcCallRegex =
212
+ /{{\s*(\$[A-Za-z0-9_]+\s*\(\s*[A-Za-z0-9_,\s]*\s*\))\s*}}/;
213
+ let match = o.match(funcCallRegex);
214
+ if (match) {
215
+ // This looks like it could be a function call:
216
+ const syntax = espree.parse(match[1]);
217
+ // TODO: Use a proper schema for what we expect here
218
+ if (
219
+ syntax.body &&
220
+ syntax.body.length === 1 &&
221
+ syntax.body[0].type === 'ExpressionStatement'
222
+ ) {
223
+ let funcName = syntax.body[0].expression.callee.name;
224
+ let args = L.map(syntax.body[0].expression.arguments, function (arg) {
225
+ return arg.value;
226
+ });
227
+ if (funcName in context.funcs) {
228
+ return template(
229
+ o.replace(funcCallRegex, context.funcs[funcName].apply(null, args)),
230
+ context
231
+ );
232
+ }
233
+ }
234
+ } else {
235
+ if (!o.match(/{{/)) {
236
+ return o;
237
+ }
238
+
239
+ result = renderVariables(o, context.vars);
240
+ }
241
+ } else {
242
+ return o;
243
+ }
244
+
245
+ return result;
246
+ }
247
+
248
+ // Mutates the object in place
249
+ function templateObjectOrArray(o, context) {
250
+ deepForEach(o, (value, key, subj, path) => {
251
+ const newPath = template(path, context, true);
252
+
253
+ let newValue;
254
+ if (value && value.constructor !== Object && value.constructor !== Array) {
255
+ newValue = template(value, context, true);
256
+ } else {
257
+ newValue = value;
258
+ }
259
+
260
+ debug(
261
+ `path = ${path} ; value = ${JSON.stringify(
262
+ value
263
+ )} (${typeof value}) ; (subj type: ${
264
+ subj.length ? 'list' : 'hash'
265
+ }) ; newValue = ${JSON.stringify(newValue)} ; newPath = ${newPath}`
266
+ );
267
+
268
+ // If path has changed, we need to unset the original path and
269
+ // explicitly walk down the new subtree from this path:
270
+ if (path !== newPath) {
271
+ L.unset(o, path);
272
+ newValue = template(value, context, true);
273
+ }
274
+
275
+ if (newPath.endsWith(key)) {
276
+ const keyIndex = newPath.lastIndexOf(key);
277
+ const prefix = newPath.substr(0, keyIndex - 1);
278
+ L.set(o, `${prefix}["${key}"]`, newValue);
279
+ } else {
280
+ L.set(o, newPath, newValue);
281
+ }
282
+ });
283
+ }
284
+
285
+ function renderVariables(str, vars) {
286
+ const RX = /{{{?[\s$\w\.\[\]\'\"-]+}}}?/g;
287
+ let rxmatch;
288
+ let result = str.substring(0, str.length);
289
+
290
+ // Special case for handling integer/boolean/object substitution:
291
+ //
292
+ // Does the template string contain one variable and nothing else?
293
+ // e.g.: "{{ myvar }" or "{{ myvar }", but NOT " {{ myvar }"
294
+ // If so, we treat it as a special case.
295
+ const matches = str.match(RX);
296
+ if (matches && matches.length === 1) {
297
+ if (matches[0] === str) {
298
+ // there's nothing else in the template but the variable
299
+ const varName = str.replace(/{/g, '').replace(/}/g, '').trim();
300
+ return sanitiseValue(L.get(vars, varName));
301
+ }
302
+ }
303
+
304
+ while (result.search(RX) > -1) {
305
+ let templateStr = result.match(RX)[0];
306
+ const varName = templateStr.replace(/{/g, '').replace(/}/g, '').trim();
307
+
308
+ let varValue = L.get(vars, varName);
309
+
310
+ if (typeof varValue === 'object') {
311
+ varValue = JSON.stringify(varValue);
312
+ }
313
+ result = result.replace(templateStr, varValue);
314
+ }
315
+
316
+ return result;
317
+ }
318
+
319
+ // Presume code is valid JS code (i.e. that it has been checked elsewhere)
320
+ function evil(sandbox, code) {
321
+ let context = vm.createContext(sandbox);
322
+ let script = new vm.Script(code);
323
+ try {
324
+ return script.runInContext(context);
325
+ } catch (e) {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ function parseLoopCount(countSpec) {
331
+ let from = 0;
332
+ let to = 0;
333
+
334
+ if (typeof countSpec === 'number') {
335
+ from = 0;
336
+ to = countSpec;
337
+ } else if (typeof countSpec === 'string') {
338
+ if (isNaN(Number(countSpec))) {
339
+ if (/\d\-\d/.test(countSpec)) {
340
+ from = Number(countSpec.split('-')[0]);
341
+ to = Number(countSpec.split('-')[1]);
342
+ } else {
343
+ to = 0;
344
+ }
345
+ } else {
346
+ to = Number(countSpec);
347
+ }
348
+ } else {
349
+ to = 0;
350
+ }
351
+
352
+ return { from: from, to: to };
353
+ }
354
+
355
+ function isCaptureFailed(v, defaultStrict) {
356
+ const noValue =
357
+ typeof v.value === 'undefined' ||
358
+ v.value === '' ||
359
+ typeof v.error !== 'undefined';
360
+
361
+ if (!noValue) {
362
+ return false;
363
+ }
364
+
365
+ return !(
366
+ (typeof defaultStrict === 'undefined' && v.strict === false) ||
367
+ (defaultStrict === true && v.strict === false) ||
368
+ (defaultStrict === false && typeof v.strict === 'undefined') ||
369
+ (defaultStrict === false && v.strict === false)
370
+ );
371
+ }
372
+
373
+ // Helper function to wrap an object's property in a list if it's
374
+ // defined, or set it to an empty list if not.
375
+ function ensurePropertyIsAList(obj, prop) {
376
+ if (Array.isArray(obj[prop])) {
377
+ return obj;
378
+ }
379
+
380
+ obj[prop] = [].concat(typeof obj[prop] === 'undefined' ? [] : obj[prop]);
381
+ return obj;
382
+ }
383
+
384
+ function captureOrMatch(params, response, context, done) {
385
+ if (
386
+ (!params.capture || params.capture.length === 0) &&
387
+ (!params.match || params.match.length === 0)
388
+ ) {
389
+ return done(null, null);
390
+ }
391
+
392
+ let result = {
393
+ captures: {},
394
+ matches: {},
395
+ failedCaptures: false
396
+ };
397
+
398
+ // Objects updated in place the first time this runs:
399
+ ensurePropertyIsAList(params, 'capture');
400
+ ensurePropertyIsAList(params, 'match');
401
+
402
+ let specs = params.capture.concat(params.match);
403
+
404
+ async.eachSeries(
405
+ specs,
406
+ function (spec, next) {
407
+ let parsedSpec = parseSpec(spec, response);
408
+ let parser = parsedSpec.parser;
409
+ let extractor = parsedSpec.extractor;
410
+ let expr = parsedSpec.expr;
411
+
412
+ // are we looking at body or headers:
413
+ var content = response.body;
414
+ if (spec.header) {
415
+ content = response.headers;
416
+ }
417
+
418
+ parser(content, function (err, doc) {
419
+ if (err) {
420
+ if (spec.as) {
421
+ result.captures[spec.as] = {
422
+ error: err,
423
+ strict: spec.strict
424
+ };
425
+ result.captures[spec.as].failed = isCaptureFailed(
426
+ result.captures[spec.as],
427
+ context._defaultStrictCapture
428
+ );
429
+ } else {
430
+ result.matches[spec.expr] = {
431
+ error: err,
432
+ strict: spec.strict
433
+ };
434
+ }
435
+ return next(null);
436
+ }
437
+
438
+ let extractedValue = extractor(doc, template(expr, context), spec);
439
+
440
+ if (spec.value !== undefined) {
441
+ // this is a match spec
442
+ let expected = template(spec.value, context);
443
+ debug(
444
+ 'match: %s, expected: %s, got: %s',
445
+ expr,
446
+ expected,
447
+ extractedValue
448
+ );
449
+ if (extractedValue !== expected) {
450
+ result.matches[expr] = {
451
+ success: false,
452
+ expected: expected,
453
+ got: extractedValue,
454
+ expression: expr,
455
+ strict: spec.strict
456
+ };
457
+ } else {
458
+ result.matches.expr = {
459
+ success: true,
460
+ expected: expected,
461
+ expression: expr
462
+ };
463
+ }
464
+ return next(null);
465
+ }
466
+
467
+ if (spec.as) {
468
+ // this is a capture
469
+ debug('capture: %s = %s', spec.as, extractedValue);
470
+ result.captures[spec.as] = {
471
+ value: extractedValue,
472
+ strict: spec.strict
473
+ };
474
+
475
+ result.captures[spec.as].failed = isCaptureFailed(
476
+ result.captures[spec.as],
477
+ context._defaultStrictCapture
478
+ );
479
+ }
480
+
481
+ return next(null);
482
+ });
483
+ },
484
+ function (err) {
485
+ if (err) {
486
+ return done(err, null);
487
+ } else {
488
+ return done(null, result);
489
+ }
490
+ }
491
+ );
492
+ }
493
+
494
+ function parseSpec(spec, response) {
495
+ let parser;
496
+ let extractor;
497
+ let expr;
498
+
499
+ if (spec.json) {
500
+ parser = parseJSON;
501
+ extractor = extractJSONPath;
502
+ expr = spec.json;
503
+ } else if (xmlCapture && spec.xpath) {
504
+ parser = xmlCapture.parseXML;
505
+ extractor = xmlCapture.extractXPath;
506
+ expr = spec.xpath;
507
+ } else if (spec.regexp) {
508
+ parser = dummyParser;
509
+ extractor = extractRegExp;
510
+ expr = spec.regexp;
511
+ } else if (spec.header) {
512
+ parser = dummyParser;
513
+ extractor = extractHeader;
514
+ expr = spec.header;
515
+ } else if (spec.selector) {
516
+ parser = dummyParser;
517
+ extractor = extractCheerio;
518
+ expr = spec.selector;
519
+ } else {
520
+ if (isJSON(response)) {
521
+ parser = parseJSON;
522
+ extractor = extractJSONPath;
523
+ expr = spec.json;
524
+ } else if (xmlCapture && isXML(response)) {
525
+ parser = xmlCapture.parseXML;
526
+ extractor = xmlCapture.extractXPath;
527
+ expr = spec.xpath;
528
+ } else {
529
+ // We really don't know what to do here.
530
+ parser = dummyParser;
531
+ extractor = dummyExtractor;
532
+ expr = '';
533
+ }
534
+ }
535
+
536
+ return { parser: parser, extractor: extractor, expr: expr };
537
+ }
538
+
539
+ /*
540
+ * Wrap JSON.parse in a callback
541
+ */
542
+ function parseJSON(body, callback) {
543
+ let r = null;
544
+ let err = null;
545
+
546
+ try {
547
+ if (typeof body === 'string') {
548
+ r = JSON.parse(body);
549
+ } else {
550
+ r = body;
551
+ }
552
+ } catch (e) {
553
+ err = e;
554
+ }
555
+
556
+ return callback(err, r);
557
+ }
558
+
559
+ function dummyParser(body, callback) {
560
+ return callback(null, body);
561
+ }
562
+
563
+ // doc is a JSON object
564
+ function extractJSONPath(doc, expr) {
565
+ // typeof null is 'object' hence the explicit check here
566
+ if (typeof doc !== 'object' || doc === null) {
567
+ return '';
568
+ }
569
+
570
+ let results;
571
+
572
+ try {
573
+ results = jsonpath(expr, doc);
574
+ } catch (queryErr) {
575
+ debug(queryErr);
576
+ }
577
+
578
+ if (!results) {
579
+ return '';
580
+ }
581
+
582
+ if (results.length > 1) {
583
+ return results[randomInt(0, results.length - 1)];
584
+ } else {
585
+ return results[0];
586
+ }
587
+ }
588
+
589
+ // doc is a string or an object (body parsed by Request when headers indicate JSON)
590
+ function extractRegExp(doc, expr, opts) {
591
+ let group = opts.group;
592
+ let flags = opts.flags;
593
+ let str;
594
+ if (typeof doc === 'string') {
595
+ str = doc;
596
+ } else {
597
+ str = JSON.stringify(doc); // FIXME: not the same string as the one we got from the server
598
+ }
599
+ let rx;
600
+ if (flags) {
601
+ rx = new RegExp(expr, flags);
602
+ } else {
603
+ rx = new RegExp(expr);
604
+ }
605
+ let match = rx.exec(str);
606
+ if (!match) {
607
+ return '';
608
+ }
609
+ if (group && match[group]) {
610
+ return match[group];
611
+ } else if (match[0]) {
612
+ return match[0];
613
+ } else {
614
+ return '';
615
+ }
616
+ }
617
+
618
+ function extractHeader(headers, headerName) {
619
+ return headers[headerName] || '';
620
+ }
621
+
622
+ function extractCheerio(doc, expr, opts) {
623
+ let $ = cheerio.load(doc);
624
+ let els = $(expr);
625
+ let i = 0;
626
+ if (typeof opts.index !== 'undefined') {
627
+ if (opts.index === 'random') {
628
+ i = Math.ceil(Math.random() * els.get().length - 1);
629
+ } else if (opts.index === 'last') {
630
+ i = els.get().length() - 1;
631
+ } else if (typeof Number(opts.index) === 'number') {
632
+ i = Number(opts.index);
633
+ }
634
+ }
635
+ return els.slice(i, i + 1).attr(opts.attr);
636
+ }
637
+
638
+ function dummyExtractor() {
639
+ return '';
640
+ }
641
+
642
+ /*
643
+ * Given a response object determine if it's JSON
644
+ */
645
+ function isJSON(res) {
646
+ debug('isJSON: content-type = %s', res.headers['content-type']);
647
+ return (
648
+ res.headers['content-type'] &&
649
+ /^application\/json/.test(res.headers['content-type'])
650
+ );
651
+ }
652
+
653
+ /*
654
+ * Given a response object determine if it's some kind of XML
655
+ */
656
+ function isXML(res) {
657
+ return (
658
+ res.headers['content-type'] &&
659
+ (/^[a-zA-Z]+\/xml/.test(res.headers['content-type']) ||
660
+ /^[a-zA-Z]+\/[a-zA-Z]+\+xml/.test(res.headers['content-type']))
661
+ );
662
+ }
663
+
664
+ function randomInt(low, high) {
665
+ return Math.floor(Math.random() * (high - low + 1) + low);
666
+ }
667
+
668
+ function sanitiseValue(value) {
669
+ if (value === 0 || value === false || value === null || value === undefined)
670
+ return value;
671
+ return value ? value : '';
672
+ }
package/index.js ADDED
@@ -0,0 +1,4 @@
1
+ module.exports = {
2
+ engine_util: require('./engine_util'),
3
+ jitter: require('./jitter')
4
+ }
package/jitter.js ADDED
@@ -0,0 +1,34 @@
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
+ module.exports = {
8
+ jitter: jitter
9
+ };
10
+
11
+ function jitter(sApprox) {
12
+ if (!sApprox) {
13
+ return sApprox;
14
+ }
15
+
16
+ if (typeof sApprox !== 'string') {
17
+ return sApprox;
18
+ }
19
+
20
+ if (sApprox.indexOf(':') < 0) {
21
+ return sApprox;
22
+ }
23
+
24
+ let inputs = sApprox.split(':');
25
+ let nb = parseInt(inputs[0], 10);
26
+ let approxPercent = parseInt(inputs[1], 10);
27
+
28
+ let approx = approxPercent;
29
+ if (inputs[1].indexOf('%') >= 0) {
30
+ approx = (nb * approxPercent) / 100;
31
+ }
32
+
33
+ return Math.max(0, nb - approx + Math.random() * 2 * approx);
34
+ }
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@artilleryio/int-commons",
3
+ "version": "1.0.0",
4
+ "main": "./index.js",
5
+ "dependencies": {
6
+ "async": "^2.6.4",
7
+ "cheerio": "^1.0.0-rc.10",
8
+ "debug": "^4.3.1",
9
+ "deep-for-each": "^3.0.0",
10
+ "espree": "^9.4.1",
11
+ "jsonpath-plus": "^7.2.0",
12
+ "lodash": "^4.17.19"
13
+ }
14
+ }