artillery
Version:
Cloud-scale load testing. https://www.artillery.io
799 lines (703 loc) • 21.4 kB
text/typescript
/* 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 { createRequire } from 'node:module';
import vm from 'node:vm';
import async from 'async';
import * as cheerio from 'cheerio';
import createDebug from 'debug';
import deepForEach from 'deep-for-each';
import * as espree from 'espree';
import { JSONPath as jsonpath } from 'jsonpath-plus';
import L from 'lodash';
import ms from 'ms';
import { jitter } from './jitter.ts';
const debug = createDebug('engine_util');
const A = async;
const require = createRequire(import.meta.url);
// Optional dependency - loaded lazily and kept synchronous (no
// top-level await: the package must stay require()-able)
let xmlCapture: {
parseXML: ParserFn;
extractXPath: ExtractorFn;
} | null;
try {
xmlCapture = require('artillery-xml-capture');
} catch (_e) {
xmlCapture = null;
}
// TODO Write tests
// Pragmatic typing: user scripts and processor hooks feed dynamic
// values through these utilities, so specs/params and VU variables
// are typed Record<string, any> until canonical script input types
// exist (modernization plan, phase 2).
export interface TemplateContext {
vars: Record<string, any>;
funcs?: Record<string, (...args: any[]) => any>;
[key: string]: any;
}
type StepCallback = (err: Error | null | undefined, context?: any) => void;
type StepFunction = (context: any, callback: StepCallback) => void;
type ParserFn = (
body: unknown,
callback: (err: unknown, doc?: any) => void
) => unknown;
type ExtractorFn = (doc: any, expr: string, opts: Record<string, any>) => any;
interface CaptureEntry {
value?: any;
error?: unknown;
strict?: boolean;
failed?: boolean;
}
interface MatchEntry {
success?: boolean;
expected?: any;
got?: any;
expression?: string;
error?: unknown;
strict?: boolean;
}
export interface CaptureOrMatchResult {
captures: Record<string, CaptureEntry>;
matches: Record<string, MatchEntry>;
failedCaptures: boolean;
}
export {
createThink,
createLoopWithCount,
createParallel,
isProbableEnough,
template,
captureOrMatch,
evil,
ensurePropertyIsAList,
renderVariables as _renderVariables
};
function createThink(
requestSpec: Record<string, any>,
opts?: Record<string, any>
): StepFunction {
opts = opts || {};
const thinkspec = requestSpec.think;
const f: StepFunction = function think(context, callback) {
const templatedThink = template(thinkspec, context);
let thinktime: string | number = Number.isInteger(
L.toNumber(templatedThink)
)
? ms(`${templatedThink}s`)
: ms(templatedThink);
if (typeof thinktime === 'undefined') {
throw new Error(`Invalid think time: ${templatedThink || thinkspec}`);
}
if (requestSpec.jitter || opts?.jitter) {
thinktime = jitter(`${thinktime}:${requestSpec.jitter || opts?.jitter}`);
}
debug(
'think %s, %s, %s -> %s',
requestSpec.think,
requestSpec.jitter,
opts?.jitter,
thinktime
);
setTimeout(() => {
callback(null, context);
}, thinktime as number);
};
return f;
}
// "count" can be an integer (negative or positive) or a string defining a range
// like "1-15"
function createLoopWithCount(
count: number | string,
steps: StepFunction[],
opts: Record<string, any> = {}
): StepFunction {
return function aLoop(context, callback) {
let count2 = count;
if (typeof count === 'string') {
count2 = template(count, context);
}
const from = parseLoopCount(count2).from;
const to = parseLoopCount(count2).to;
let i = from;
let newContext = context;
const loopIndexVar = opts?.loopValue || '$loopCount';
const loopElementVar = opts?.loopElement || '$loopElement';
// Should we stop early because the value of "over" is not an array
let abortEarly = false;
let overValues: any[] | null = null;
let loopValue: any = i; // default to the current iteration of the loop, ie same as $loopCount
if (typeof opts.overValues !== 'undefined') {
if (opts.overValues && typeof opts.overValues === 'object') {
const ov = opts.overValues as any[];
overValues = ov;
loopValue = ov[i];
} else if (opts.overValues && typeof opts.overValues === 'string') {
overValues = L.get(context.vars, opts.overValues);
if (L.isArray(overValues)) {
loopValue = overValues[i];
} else {
abortEarly = true;
}
}
}
newContext.vars[loopElementVar] = loopValue;
newContext.vars[loopIndexVar] = i;
let shouldContinue = true;
A.whilst(
function test() {
if (abortEarly) {
return false;
}
if (opts.whileTrue) {
return shouldContinue;
}
if (overValues !== null) {
return i !== overValues.length;
} else {
return i < to || to === -1;
}
},
function repeated(cb: (err?: Error | null, context?: any) => void) {
const zero = (cb2: (err: Error | null, context: any) => void) =>
cb2(null, newContext);
const steps2 = L.flatten([zero, steps]);
A.waterfall(steps2, (err, context2) => {
if (err) {
return cb(err, context2);
}
i++;
newContext = context2;
newContext.vars[loopIndexVar]++;
if (overValues !== null) {
newContext.vars[loopElementVar] = overValues[i];
}
if (opts.whileTrue) {
opts.whileTrue(context2, function done(b: boolean) {
shouldContinue = b;
return cb(err, context2);
});
} else {
return cb(err, context2);
}
});
},
(err: Error | null | undefined, finalContext: any) => {
if (typeof finalContext === 'undefined') {
// this happens if test() returns false immediately, e.g. with
// nested loops where one of the inner loops goes over an
// empty array
return callback(err, newContext);
}
return callback(err, finalContext);
}
);
};
}
function createParallel(
steps: StepFunction[],
opts?: Record<string, any>
): StepFunction {
const limit = opts?.limitValue || 100;
return function aParallel(context, callback) {
const newContext = context;
const newCallback = callback;
// Remap the steps array to pass the context into each step.
const newSteps = L.map(steps, (step) => (callback: StepCallback) => {
step(newContext, callback);
});
// Run each of the steps in parallel.
A.parallelLimit(newSteps, limit, (err, _finalContext) => {
// We don't need to do anything with the array of contexts returned from each step at the moment.
return newCallback(err, newContext);
});
};
}
function isProbableEnough(obj: Record<string, any>): boolean {
if (typeof obj.probability === 'undefined') {
return true;
}
let probability = Number(obj.probability) || 0;
if (probability > 100) {
probability = 100;
}
const r = L.random(100);
return r < probability;
}
// Returns the same kind of value it was given - dynamic by design.
function template(o: any, context: TemplateContext, inPlace?: boolean): any {
let result;
if (typeof o === 'undefined') {
return undefined;
}
if (o && (o.constructor === Object || o.constructor === Array)) {
if (!inPlace) {
result = L.cloneDeep(o);
} else {
result = o;
}
templateObjectOrArray(result, context);
} else if (typeof o === 'string') {
if (!/{{/.test(o)) {
return o;
}
const funcCallRegex =
/{{\s*(\$[A-Za-z0-9_]+\s*\(\s*[A-Za-z0-9_,\s]*\s*\))\s*}}/;
const match = o.match(funcCallRegex);
if (match) {
// This looks like it could be a function call:
const syntax = espree.parse(match[1]);
// TODO: Use a proper schema for what we expect here
if (
syntax.body &&
syntax.body.length === 1 &&
syntax.body[0].type === 'ExpressionStatement'
) {
const funcName = syntax.body[0].expression.callee.name;
const args = L.map(
syntax.body[0].expression.arguments,
(arg) => arg.value
);
// NOTE: pre-existing behavior: throws when the script uses a
// function-call template without any context funcs defined.
const funcs = context.funcs as NonNullable<TemplateContext['funcs']>;
if (funcName in funcs) {
return template(
o.replace(funcCallRegex, funcs[funcName].apply(null, args)),
context
);
}
}
} else {
if (!o.match(/{{/)) {
return o;
}
result = renderVariables(o, context.vars);
}
} else {
return o;
}
return result;
}
// Mutates the object in place
function templateObjectOrArray(o: any, context: TemplateContext): void {
deepForEach(o, (value, key, subj, path) => {
const newPath = template(path, context, true);
let newValue;
if (value && value.constructor !== Object && value.constructor !== Array) {
newValue = template(value, context, true);
} else {
newValue = value;
}
debug(
`path = ${path} ; value = ${JSON.stringify(
value
)} (${typeof value}) ; (subj type: ${
subj.length ? 'list' : 'hash'
}) ; newValue = ${JSON.stringify(newValue)} ; newPath = ${newPath}`
);
// If path has changed, we need to unset the original path and
// explicitly walk down the new subtree from this path:
if (path !== newPath) {
L.unset(o, path);
newValue = template(value, context, true);
}
if (newPath.endsWith(key)) {
const keyIndex = newPath.lastIndexOf(key);
const prefix = newPath.substr(0, keyIndex - 1);
L.set(o, `${prefix}["${key}"]`, newValue);
} else {
L.set(o, newPath, newValue);
}
});
}
function renderVariables(str: string, vars: Record<string, any>): any {
const RX = /{{{?[\s$\w.[\]'"-]+}}}?/g;
let _rxmatch;
let result = str.substring(0, str.length);
// Special case for handling integer/boolean/object substitution:
//
// Does the template string contain one variable and nothing else?
// e.g.: "{{ myvar }" or "{{ myvar }", but NOT " {{ myvar }"
// If so, we treat it as a special case.
const matches = str.match(RX);
if (matches && matches.length === 1) {
if (matches[0] === str) {
// there's nothing else in the template but the variable
const varName = str.replace(/{/g, '').replace(/}/g, '').trim();
return sanitiseValue(L.get(vars, varName));
}
}
while (result.search(RX) > -1) {
const templateStr = (result.match(RX) as RegExpMatchArray)[0];
const varName = templateStr.replace(/{/g, '').replace(/}/g, '').trim();
let varValue = L.get(vars, varName);
if (typeof varValue === 'object') {
varValue = JSON.stringify(varValue);
}
result = result.replace(templateStr, varValue);
}
return result;
}
// Presume code is valid JS code (i.e. that it has been checked elsewhere)
function evil(sandbox: Record<string, any>, code: string): unknown {
const context = vm.createContext(sandbox);
const script = new vm.Script(code);
try {
return script.runInContext(context);
} catch (_e) {
return null;
}
}
function parseLoopCount(countSpec: number | string): {
from: number;
to: number;
} {
let from = 0;
let to = 0;
if (typeof countSpec === 'number') {
from = 0;
to = countSpec;
} else if (typeof countSpec === 'string') {
if (Number.isNaN(Number(countSpec))) {
if (/\d-\d/.test(countSpec)) {
from = Number(countSpec.split('-')[0]);
to = Number(countSpec.split('-')[1]);
} else {
to = 0;
}
} else {
to = Number(countSpec);
}
} else {
to = 0;
}
return { from: from, to: to };
}
function isCaptureFailed(
v: Record<string, any>,
defaultStrict?: boolean
): boolean {
const noValue =
typeof v.value === 'undefined' ||
v.value === '' ||
typeof v.error !== 'undefined';
if (!noValue) {
return false;
}
return !(
(typeof defaultStrict === 'undefined' && v.strict === false) ||
(defaultStrict === true && v.strict === false) ||
(defaultStrict === false && typeof v.strict === 'undefined') ||
(defaultStrict === false && v.strict === false)
);
}
// Helper function to wrap an object's property in a list if it's
// defined, or set it to an empty list if not.
function ensurePropertyIsAList(
obj: Record<string, any>,
prop: string
): Record<string, any> {
if (Array.isArray(obj[prop])) {
return obj;
}
obj[prop] = ([] as any[]).concat(
typeof obj[prop] === 'undefined' ? [] : obj[prop]
);
return obj;
}
function captureOrMatch(
params: Record<string, any>,
// WS/Socket.IO engines pass body-only faux responses:
response: { body?: unknown; headers?: Record<string, any> },
context: TemplateContext,
done: (err: Error | null, result: CaptureOrMatchResult | null) => void
) {
if (
(!params.capture || params.capture.length === 0) &&
(!params.match || params.match.length === 0)
) {
return done(null, null);
}
const result: CaptureOrMatchResult = {
captures: {},
matches: {},
failedCaptures: false
};
// Objects updated in place the first time this runs:
ensurePropertyIsAList(params, 'capture');
ensurePropertyIsAList(params, 'match');
const specs = params.capture.concat(params.match);
async.eachSeries(
specs,
(spec: Record<string, any>, next) => {
const parsedSpec = parseSpec(spec, response);
const parser = parsedSpec.parser;
const extractor = parsedSpec.extractor;
const expr = parsedSpec.expr;
// are we looking at body or headers:
var content = response.body;
if (spec.header) {
content = response.headers;
}
parser(content, (err: unknown, doc: any) => {
if (err) {
if (spec.as) {
result.captures[spec.as] = {
error: err,
strict: spec.strict
};
result.captures[spec.as].failed = isCaptureFailed(
result.captures[spec.as],
context._defaultStrictCapture
);
} else {
result.matches[spec.expr] = {
error: err,
strict: spec.strict
};
}
return next(null);
}
const extractedValue = extractor(doc, template(expr, context), spec);
if (spec.value !== undefined) {
// this is a match spec
const expected = template(spec.value, context);
debug(
'match: %s, expected: %s, got: %s',
expr,
expected,
extractedValue
);
if (extractedValue !== expected) {
result.matches[expr] = {
success: false,
expected: expected,
got: extractedValue,
expression: expr,
strict: spec.strict
};
} else {
result.matches.expr = {
success: true,
expected: expected,
expression: expr
};
}
return next(null);
}
if (spec.as) {
// this is a capture
debug('capture: %s = %s', spec.as, extractedValue);
result.captures[spec.as] = {
value: extractedValue,
strict: spec.strict
};
result.captures[spec.as].failed = isCaptureFailed(
result.captures[spec.as],
context._defaultStrictCapture
);
}
return next(null);
});
},
(err) => {
if (err) {
return done(err, null);
} else {
return done(null, result);
}
}
);
}
function parseSpec(
spec: Record<string, any>,
response: { body?: unknown; headers?: Record<string, any> }
): { parser: ParserFn; extractor: ExtractorFn; expr: string } {
let parser: ParserFn;
let extractor: ExtractorFn;
let expr: string;
if (spec.json) {
parser = parseJSON;
extractor = extractJSONPath;
expr = spec.json;
} else if (xmlCapture && spec.xpath) {
parser = xmlCapture.parseXML;
extractor = xmlCapture.extractXPath;
expr = spec.xpath;
} else if (spec.regexp) {
parser = dummyParser;
extractor = extractRegExp;
expr = spec.regexp;
} else if (spec.header) {
parser = dummyParser;
extractor = extractHeader;
expr = spec.header;
} else if (spec.selector) {
parser = dummyParser;
extractor = extractCheerio;
expr = spec.selector;
} else {
if (isJSON(response)) {
parser = parseJSON;
extractor = extractJSONPath;
expr = spec.json;
} else if (xmlCapture && isXML(response)) {
parser = xmlCapture.parseXML;
extractor = xmlCapture.extractXPath;
expr = spec.xpath;
} else {
// We really don't know what to do here.
parser = dummyParser;
extractor = dummyExtractor;
expr = '';
}
}
return { parser: parser, extractor: extractor, expr: expr };
}
/*
* Wrap JSON.parse in a callback
*/
function parseJSON(body: unknown, callback: (err: unknown, doc?: any) => void) {
let r: any = null;
let err: unknown = null;
try {
if (typeof body === 'string') {
r = JSON.parse(body);
} else {
r = body;
}
} catch (e) {
err = e;
}
return callback(err, r);
}
function dummyParser(
body: unknown,
callback: (err: unknown, doc?: any) => void
) {
return callback(null, body);
}
// doc is a JSON object
function extractJSONPath(
doc: any,
expr: string,
opts: Record<string, any>
): any {
// typeof null is 'object' hence the explicit check here
if (typeof doc !== 'object' || doc === null) {
return '';
}
let results;
try {
results = jsonpath({ path: expr, json: doc, wrap: opts.multiple ?? true });
} catch (queryErr) {
debug(queryErr);
}
if (!results) {
return '';
}
if (opts.multiple === false) {
return results;
}
if (results.length > 1) {
return results[randomInt(0, results.length - 1)];
} else {
return results[0];
}
}
// doc is a string or an object (body parsed by Request when headers indicate JSON)
function extractRegExp(
doc: unknown,
expr: string,
opts: Record<string, any>
): string | undefined {
const group = opts.group;
const flags = opts.flags;
let str: string;
if (typeof doc === 'string') {
str = doc;
} else {
str = JSON.stringify(doc); // FIXME: not the same string as the one we got from the server
}
let rx;
if (flags) {
rx = new RegExp(expr, flags);
} else {
rx = new RegExp(expr);
}
const match = rx.exec(str);
if (!match) {
return '';
}
// Captures named group (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group)
if (group && match.groups) {
return match.groups[group];
// Captures integer index defined group since those don't show up in match.groups
} else if (group && match[group]) {
return match[group];
// Defaults to first match if found and no group defined
} else if (match[0]) {
return match[0];
// If no match returns empty string
} else {
return '';
}
}
function extractHeader(headers: any, headerName: string): any {
return headers[headerName] || '';
}
function extractCheerio(
doc: any,
expr: string,
opts: Record<string, any>
): string | undefined {
const $ = cheerio.load(doc);
const els = $(expr);
let i = 0;
if (typeof opts.index !== 'undefined') {
if (opts.index === 'random') {
i = Math.ceil(Math.random() * els.get().length - 1);
} else if (opts.index === 'last') {
i = els.get().length - 1;
} else if (typeof Number(opts.index) === 'number') {
i = Number(opts.index);
}
}
return els.slice(i, i + 1).attr(opts.attr);
}
function dummyExtractor() {
return '';
}
/*
* Given a response object determine if it's JSON
*/
function isJSON(res: { headers?: Record<string, any> }): boolean {
// NOTE: pre-existing behavior: throws when a body-only faux response
// reaches the content-type fallback (no capture type and no headers).
const headers = res.headers as Record<string, any>;
debug('isJSON: content-type = %s', headers['content-type']);
return Boolean(
headers['content-type'] &&
/^application\/json/.test(headers['content-type'])
);
}
/*
* Given a response object determine if it's some kind of XML
*/
function isXML(res: { headers?: Record<string, any> }): boolean {
const headers = res.headers as Record<string, any>;
return Boolean(
headers['content-type'] &&
(/^[a-zA-Z]+\/xml/.test(headers['content-type']) ||
/^[a-zA-Z]+\/[a-zA-Z]+\+xml/.test(headers['content-type']))
);
}
function randomInt(low: number, high: number): number {
return Math.floor(Math.random() * (high - low + 1) + low);
}
function sanitiseValue(value: any): any {
if (value === 0 || value === false || value === null || value === undefined)
return value;
return value ? value : '';
}