artillery
Version:
Cloud-scale load testing. https://www.artillery.io
827 lines (826 loc) • 39.3 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 crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import qs from 'node:querystring';
import { parse as urlparse } from 'node:url';
import { callbackify, promisify } from 'node:util';
import HttpAgent from 'agentkeepalive';
import async from 'async';
import createDebug from 'debug';
import decompressResponse from 'decompress-response';
import { compileExpression as filtrex } from 'filtrex';
import FormData from 'form-data';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
import _ from 'lodash';
import * as tough from 'tough-cookie';
import { engine_util as engineUtil } from "../commons/index.js";
const debug = createDebug('http');
const debugRequests = createDebug('http:request');
const debugResponse = createDebug('http:response');
const USER_AGENT = 'Artillery (https://artillery.io)';
const ensurePropertyIsAList = engineUtil.ensurePropertyIsAList;
const template = engineUtil.template;
const { HttpsAgent } = HttpAgent;
const GOT_OPTION_NAMES = [
'url',
'searchParams',
'method',
'headers',
'body',
'json',
'form',
'allowGetBody',
'timeout',
'retry',
'encoding',
'cookieJar',
'followRedirect',
'maxRedirects',
'decompress',
'http2',
'agent',
'username',
'password',
'https',
'throwHttpErrors'
];
// NOTE: pre-existing quirk preserved: this object is mutated by
// Object.assign() in the constructor and in setInitialContext(), so
// options accumulate on the shared constant across instances.
const DEFAULT_AGENT_OPTIONS = {
keepAlive: true,
keepAliveMsec: 1000
};
// agentkeepalive defaults `timeout` (active socket inactivity) to a hardcoded
// floor of 8000ms (Math.max(freeSocketTimeout * 2, 8000)). When a request's
// origin doesn't send response bytes within 8s, the socket is destroyed with
// `error.code = 'ERR_SOCKET_TIMEOUT'` regardless of the user's configured
// `http.timeout`. Mirror `http.timeout` (or top-level `timeout`) into the agent
// so the YAML config becomes the actual binding constraint.
function deriveAgentTimeoutMs(scriptConfig) {
const timeoutSec = scriptConfig?.timeout || scriptConfig?.http?.timeout;
if (typeof timeoutSec !== 'number')
return undefined;
// Never go below the existing 8s floor — preserves current behaviour for
// users who explicitly configure a smaller http.timeout.
return Math.max(8000, timeoutSec * 1000);
}
function createAgents(proxies, opts) {
const agentOpts = Object.assign({}, DEFAULT_AGENT_OPTIONS, opts);
// HTTP proxy endpoint will be used for all requests, unless a separate
// HTTPS proxy URL is also set, which will be used for HTTPS requests:
if (proxies.http) {
agentOpts.proxy = proxies.http;
const httpAgent = new HttpProxyAgent(agentOpts);
if (proxies.https) {
agentOpts.proxy = proxies.https;
}
const httpsAgent = new HttpsProxyAgent(agentOpts);
return { httpAgent, httpsAgent };
}
// If only HTTPS proxy is provided, it will be used for HTTPS requests,
// but not for HTTP requests:
if (proxies.https) {
return {
httpAgent: new HttpAgent(agentOpts),
httpsAgent: new HttpsProxyAgent(Object.assign({ proxy: proxies.https }, agentOpts))
};
}
// By default nothing is proxied:
return {
httpAgent: new HttpAgent(agentOpts),
httpsAgent: new HttpsAgent(agentOpts)
};
}
export default class HttpEngine {
constructor(script) {
this.config = script.config;
if (typeof this.config.defaults === 'undefined') {
this.config.defaults = {};
}
if (typeof this.config.http === 'undefined') {
this.config.http = {};
}
if (typeof this.config.http.defaults === 'undefined') {
this.config.http.defaults = {};
}
if (typeof this.config.http.cookieJarOptions === 'undefined') {
this.config.http.cookieJarOptions = {};
}
// If config.http.pool is set, create & reuse agents for all requests (with
// max sockets set). That's what we're done here.
// If config.http.pool is not set, we create new agents for each virtual user.
// That's done when the VU is initialized.
this.maxSockets = Infinity;
if (script.config.http?.pool) {
this.maxSockets = Number(script.config.http.pool);
}
const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
maxSockets: this.maxSockets,
maxFreeSockets: this.maxSockets
});
const agentTimeoutMs = deriveAgentTimeoutMs(script.config);
if (agentTimeoutMs !== undefined) {
agentOpts.timeout = agentTimeoutMs;
}
const agents = createAgents({
http: process.env.HTTP_PROXY,
https: process.env.HTTPS_PROXY
}, agentOpts);
this._httpAgent = agents.httpAgent;
this._httpsAgent = agents.httpsAgent;
if ((script.config.http && script.config.http.extendedMetrics === true) ||
global.artillery?.runtimeOptions.extendedHTTPMetrics) {
this.extendedHTTPMetrics = true;
}
}
async init() {
this.request = (await import('got')).default;
}
_isDistributedTracingEnabled(config) {
const dtConfig = config.http?.distributedTracing;
if (!dtConfig) {
return false;
}
// Handle both boolean and object forms
if (typeof dtConfig === 'boolean') {
return dtConfig;
}
if (typeof dtConfig === 'object' && dtConfig.enabled !== undefined) {
return dtConfig.enabled;
}
// Default to true if distributedTracing is set but enabled is not specified
return true;
}
_generateTraceparent(config) {
// W3C Trace Context format: version-trace-id-parent-id-trace-flags
const version = '00';
// Get configuration
const dtConfig = config.http?.distributedTracing;
let sampled = true; // Default to sampled
let traceIdPrefix = 'a9'; // Default prefix
if (typeof dtConfig === 'object') {
if (dtConfig.sampled !== undefined) {
sampled = dtConfig.sampled;
}
if (dtConfig.traceIdPrefix !== undefined) {
traceIdPrefix = dtConfig.traceIdPrefix;
}
}
// Validate and normalize prefix (must be valid hex, max 8 chars)
traceIdPrefix = traceIdPrefix
.toLowerCase()
.replace(/[^0-9a-f]/g, '')
.slice(0, 8);
if (traceIdPrefix.length === 0) {
traceIdPrefix = 'a9'; // Fallback to default if invalid
}
// Generate trace-id with prefix (32 hex chars total)
const remainingBytes = Math.ceil((32 - traceIdPrefix.length) / 2);
const randomPart = crypto.randomBytes(remainingBytes).toString('hex');
const traceId = (traceIdPrefix + randomPart).slice(0, 32);
// Generate 8-byte parent-id (16 hex chars)
const parentId = crypto.randomBytes(8).toString('hex');
const traceFlags = sampled ? '01' : '00';
return `${version}-${traceId}-${parentId}-${traceFlags}`;
}
createScenario(scenarioSpec, ee) {
ensurePropertyIsAList(scenarioSpec, 'beforeRequest');
ensurePropertyIsAList(scenarioSpec, 'afterResponse');
ensurePropertyIsAList(scenarioSpec, 'beforeScenario');
ensurePropertyIsAList(scenarioSpec, 'afterScenario');
ensurePropertyIsAList(scenarioSpec, 'onError');
// Add scenario-level hooks if needed:
// For now, just turn them into function steps and insert them
// directly into the flow array.
// TODO: Scenario-level hooks will probably want access to the
// entire scenario spec rather than just the userContext.
const beforeScenarioFns = _.map(scenarioSpec.beforeScenario, (hookFunctionName) => ({ function: hookFunctionName }));
const afterScenarioFns = _.map(scenarioSpec.afterScenario, (hookFunctionName) => ({ function: hookFunctionName }));
const newFlow = beforeScenarioFns.concat(scenarioSpec.flow.concat(afterScenarioFns));
scenarioSpec.flow = newFlow;
const tasks = _.map(scenarioSpec.flow, (rs) => this.step(rs, ee, {
beforeRequest: scenarioSpec.beforeRequest,
afterResponse: scenarioSpec.afterResponse,
onError: scenarioSpec.onError
}));
return this.compile(tasks, scenarioSpec, ee);
}
step(requestSpec, ee, opts) {
opts = opts || {};
const self = this;
const config = this.config;
if (requestSpec.loop) {
const steps = _.map(requestSpec.loop, (rs) => self.step(rs, ee, opts));
return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
loopValue: requestSpec.loopValue || '$loopCount',
loopElement: requestSpec.loopElement || '$loopElement',
overValues: requestSpec.over,
whileTrue: self.config.processor
? self.config.processor[requestSpec.whileTrue]
: undefined
});
}
if (requestSpec.parallel) {
const steps = _.map(requestSpec.parallel, (rs) => self.step(rs, ee, opts));
return engineUtil.createParallel(steps, {
limitValue: requestSpec.limit
});
}
if (typeof requestSpec.think !== 'undefined') {
return engineUtil.createThink(requestSpec, self.config.http.defaults.think || self.config.defaults.think);
}
if (typeof requestSpec.log !== 'undefined') {
return (context, callback) => {
console.log(template(requestSpec.log, context));
return process.nextTick(() => {
callback(null, context);
});
};
}
if (requestSpec.function) {
return (context, callback) => {
const processFunc = self.config.processor?.[requestSpec.function];
if (processFunc) {
let f;
if (processFunc.constructor.name === 'Function') {
f = processFunc;
}
else {
f = callbackify(processFunc);
}
return f(context, ee, (hookErr) => callback(hookErr, context));
}
else {
debug(`Function "${requestSpec.function}" not defined`);
debug('processor: %o', self.config.processor);
ee.emit('error', `Undefined function "${requestSpec.function}"`);
return process.nextTick(() => {
callback(null, context);
});
}
};
}
const f = (context, callback) => {
const method = _.keys(requestSpec)[0].toUpperCase();
const params = requestSpec[method.toLowerCase()];
const onErrorHandlers = opts.onError; // only scenario-lever onError handlers are supported
// A special case for when "url" attribute is missing. We need to check for
// it manually as request.js won't emit an 'error' event when the argument
// is missing.
// This will be obsoleted by better script validation.
if (!params.url && !params.uri) {
const err = new Error('an URL must be specified');
return callback(err, context);
}
const tls = config.tls || {};
const timeout = config.timeout || _.get(config, 'http.timeout') || 10;
if (!engineUtil.isProbableEnough(params)) {
return process.nextTick(() => {
callback(null, context);
});
}
if (!_.isUndefined(params.ifTrue)) {
let result;
try {
const cond = _.has(config.processor, params.ifTrue)
? config.processor[params.ifTrue]
: filtrex(params.ifTrue);
result = cond(context.vars);
}
catch (err) {
debug('ifTrue error:', err);
result = 1; // if the expression is incorrect, just proceed
}
if (!result) {
return process.nextTick(() => {
callback(null, context);
});
}
}
// Run beforeRequest processors (scenario-level ones too)
const requestParams = _.extend(_.clone(params), {
url: maybePrependBase(params.url || params.uri, config), // *NOT* templating here
method: method,
timeout: timeout,
uuid: crypto.randomUUID()
});
if (context._enableCookieJar) {
requestParams.cookieJar = context._jar;
}
if (tls) {
requestParams.https = requestParams.https || {};
requestParams.https = _.extend(requestParams.https, tls);
}
const functionNames = _.concat(opts.beforeRequest || [], params.beforeRequest || []);
async.eachSeries(functionNames, function iteratee(functionName, next) {
const fn = template(functionName, context);
let processFunc = config.processor?.[fn];
if (!processFunc) {
processFunc = (_r, _c, _e, cb) => cb(null);
console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
}
if (processFunc.constructor.name === 'Function') {
processFunc(requestParams, context, ee, (err) => {
if (err) {
return next(err);
}
return next(null);
});
}
else {
processFunc(requestParams, context, ee).then(next).catch(next);
}
}, function done(err) {
if (err) {
debug(err);
return callback(err, context);
}
// Order of precedence: json set in a function, json set in the script, body set in a function, body set in the script.
if (requestParams.json) {
requestParams.json = template(requestParams.json, context);
delete requestParams.body;
}
else if (requestParams.body) {
requestParams.body = template(requestParams.body, context);
// TODO: Warn if body is not a string or a buffer
}
// add loop, name & uri elements to be interpolated
if (context.vars.$loopElement) {
context.vars.$loopElement = template(context.vars.$loopElement, context);
}
if (requestParams.name) {
requestParams.name = template(requestParams.name, context);
}
if (requestParams.uri) {
requestParams.uri = template(requestParams.uri, context);
}
if (requestParams.url) {
requestParams.url = template(requestParams.url, context);
}
// Follow all redirects by default unless specified otherwise
if (typeof requestParams.followRedirect === 'undefined') {
requestParams.followRedirect = true;
}
// TODO: Use traverse on the entire flow instead
// Request.js -> Got.js translation
if (params.qs) {
requestParams.searchParams = qs.stringify(template(params.qs, context));
}
if (typeof params.gzip === 'boolean') {
requestParams.decompress = params.gzip;
}
else {
requestParams.decompress = true;
}
if (params.form) {
requestParams.form = _.reduce(requestParams.form, (acc, v, k) => {
acc[k] = template(v, context);
return acc;
}, {});
}
if (params.formData) {
let fileUpload;
const f = new FormData();
requestParams.body = _.reduce(requestParams.formData, (acc, v, k) => {
let V = template(v, context);
let options;
if (V && _.isPlainObject(V)) {
if (V.contentType) {
options = { contentType: V.contentType };
}
if (V.fromFile) {
const absPath = path.resolve(path.dirname(context.vars.$scenarioFile), V.fromFile);
fileUpload = absPath;
V = fs.createReadStream(absPath);
}
else if (V.value) {
V = V.value;
}
}
acc.append(k, V, options);
return acc;
}, f);
if (params.setContentLengthHeader && fileUpload) {
try {
requestParams.headers = requestParams.headers || {};
requestParams.headers['content-length'] =
fs.statSync(fileUpload).size;
}
catch (err) {
debug(`stat() on ${fileUpload} failed with ${err}`);
}
}
}
// Assign default headers then overwrite as needed
const defaultHeaders = lowcaseKeys(config.http.defaults.headers ||
config.defaults.headers || { 'user-agent': USER_AGENT });
const combinedHeaders = _.extend(defaultHeaders, lowcaseKeys(params.headers), lowcaseKeys(requestParams.headers));
const templatedHeaders = _.mapValues(combinedHeaders, (v, _k, _obj) => template(v, context));
requestParams.headers = templatedHeaders;
// We compute the url here so that the cookies are set properly afterwards
const url = maybePrependBase(template(requestParams.uri || requestParams.url, context), config);
if (requestParams.uri) {
// If a hook function sets requestParams.uri to something, request.js
// will pick that over .url, so we need to delete it.
delete requestParams.uri;
}
requestParams.url = url;
if (typeof requestParams.cookie === 'object' ||
typeof context._defaultCookie === 'object') {
requestParams.cookieJar = context._jar;
const cookie = Object.assign({}, context._defaultCookie, requestParams.cookie);
Object.keys(cookie).forEach((k) => {
context._jar.setCookieSync(`${k}=${template(cookie[k], context)}`, requestParams.url);
});
}
if (typeof requestParams.auth === 'object') {
requestParams.username = template(requestParams.auth.user, context);
requestParams.password = template(requestParams.auth.pass, context);
delete requestParams.auth;
}
// TODO: Bypass proxy if "proxy: false" is set
requestParams.agent = {
http: context._httpAgent,
https: context._httpsAgent
};
requestParams.throwHttpErrors = false;
if (!requestParams.url || !requestParams.url.startsWith('http')) {
const err = new Error(`Invalid URL - ${requestParams.url}`);
return callback(err, context);
}
function responseProcessor(isLast, res, body, done) {
if (process.env.DEBUG) {
let requestInfo = {
url: requestParams.url,
method: requestParams.method,
headers: requestParams.headers
};
// Internal handle of the wrapped cookie jar - debug only:
const jarInternal = context._jar._jar;
if (jarInternal &&
typeof jarInternal.getCookieStringSync === 'function') {
requestInfo = Object.assign(requestInfo, {
cookie: jarInternal.getCookieStringSync(requestParams.url)
});
}
if (requestParams.json &&
typeof requestParams.json !== 'boolean') {
requestInfo.json = requestParams.json;
}
// If "json" is set to an object, it will be serialised and sent as body and the value of the "body" attribute will be ignored.
if (requestParams.body &&
typeof requestParams.json !== 'object') {
if (process.env.DEBUG.indexOf('http:full_body') > -1) {
// Show the entire body
requestInfo.body = requestParams.body;
}
else {
// Only show the beginning of long bodies
if (typeof requestParams.body === 'string') {
requestInfo.body = requestParams.body.substring(0, 512);
if (requestParams.body.length > 512) {
requestInfo.body += ' ...';
}
}
else if (typeof requestParams.body === 'object') {
requestInfo.body = `< ${requestParams.body.constructor.name} >`;
}
else {
requestInfo.body = String(requestInfo.body);
}
}
}
if (requestParams.qs) {
requestInfo.qs = qs.encode(Object.assign(qs.parse(urlparse(requestParams.url).query ?? ''), template(requestParams.qs, context)));
}
debug('request: %s', JSON.stringify(requestInfo, null, 2));
}
debugResponse(JSON.stringify(res.headers, null, 2));
debugResponse(JSON.stringify(body, null, 2));
// capture/match/response hooks run only for last request in a task
if (!isLast) {
return done(null, context);
}
const resForCapture = { headers: res.headers, body: body };
engineUtil.captureOrMatch(params, resForCapture, context, function captured(err, result) {
if (err) {
// Run onError hooks and end the scenario:
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => done(err, context));
}
let haveFailedMatches = false;
let haveFailedCaptures = false;
if (result !== null) {
ee.emit('trace:http:capture', result, requestParams.uuid);
if (Object.keys(result.matches).length > 0 ||
Object.keys(result.captures).length > 0) {
debug('captures and matches:');
debug(result.matches);
debug(result.captures);
}
// match and capture are strict by default:
haveFailedMatches = _.some(result.matches, (v, _k) => !v.success && v.strict !== false);
haveFailedCaptures = _.some(result.captures, (v, _k) => v.failed);
if (haveFailedMatches || haveFailedCaptures) {
// TODO: Emit the details of each failed capture/match
}
else {
_.each(result.matches, (v, _k) => {
ee.emit('match', v.success, {
expected: v.expected,
got: v.got,
expression: v.expression,
strict: v.strict
});
});
_.each(result.captures, (v, k) => {
_.set(context.vars, k, v.value);
});
}
}
// Now run afterResponse processors
const functionNames = _.concat(opts?.afterResponse || [], params.afterResponse || []);
async.eachSeries(functionNames, function iteratee(functionName, next) {
const fn = template(functionName, context);
let processFunc = config.processor?.[fn];
if (!processFunc) {
// TODO: DRY - #223
processFunc = (_r, _res, _c, _e, cb) => cb(null);
console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
}
// Got does not have res.body which Request.js used to have, so we attach it here:
res.body = body;
if (processFunc.constructor.name === 'Function') {
processFunc(requestParams, res, context, ee, (err) => {
if (err) {
return next(err);
}
return next(null);
});
}
else {
processFunc(requestParams, res, context, ee)
.then(next)
.catch(next);
}
}, (err) => {
if (err) {
debug(err);
return done(err, context);
}
if (haveFailedMatches || haveFailedCaptures) {
// FIXME: This means only one error in the report even if multiple captures failed for the same request.
return done(new Error('Failed capture or match'), context);
}
return done(null, context);
});
});
}
let needToProcessResponse = false;
if (typeof requestParams.capture === 'object' ||
typeof requestParams.match === 'object' ||
requestParams.afterResponse ||
(typeof opts?.afterResponse === 'object' &&
opts.afterResponse.length > 0) ||
process.env.DEBUG) {
needToProcessResponse = true;
}
if (!requestParams.url) {
const err = new Error('an URL must be specified');
// Run onError hooks and end the scenario
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
}
requestParams.retry = { limit: 0 }; // disable retries - ignored when using streams
// Convert scalar seconds to Got v14 timeout object right before request
const gotOptions = _.pick(requestParams, GOT_OPTION_NAMES);
gotOptions.timeout = { response: requestParams.timeout * 1000 };
// Add W3C Trace Context headers if distributed tracing is enabled
if (self._isDistributedTracingEnabled(config)) {
const traceparent = self._generateTraceparent(config);
gotOptions.headers = gotOptions.headers || {};
gotOptions.headers.traceparent = traceparent;
}
let totalDownloaded = 0;
self
.request(gotOptions)
.on('request', (req) => {
ee.emit('trace:http:request', requestParams, requestParams.uuid);
debugRequests('request start: %s', req.path);
ee.emit('counter', 'http.requests', 1);
ee.emit('rate', 'http.request_rate');
req.on('response', (res) => {
res.on('end', () => {
ee.emit('counter', 'http.downloaded_bytes', totalDownloaded);
});
ee.emit('trace:http:response', res, requestParams.uuid);
self._handleResponse(requestParams, res, ee, context, needToProcessResponse ? responseProcessor : null, callback);
});
})
.on('downloadProgress', (progress) => {
totalDownloaded = progress.total;
})
.on('error', (err) => {
ee.emit('trace:http:error', err, requestParams.uuid);
if (err.name === 'HTTPError') {
return;
}
// this is an ENOTFOUND, ECONNRESET etc
debug(err);
// Run onError hooks and end the scenario:
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
})
.catch((gotErr) => {
// TODO: Handle the error properly with run hooks
debug(gotErr);
runOnErrorHooks(onErrorHandlers, config.processor, gotErr, requestParams, context, ee, (_asyncErr) => callback(gotErr, context));
});
}); // eachSeries
};
return f;
}
_handleResponse(requestParams, res, ee, context, responseProcessor, callback) {
const url = requestParams.url;
if (requestParams.decompress) {
res = decompressResponse(res);
}
const code = res.statusCode;
if (!context._enableCookieJar) {
const rawCookies = res.headers['set-cookie'];
if (rawCookies) {
context._enableCookieJar = true;
rawCookies.forEach((cookieString) => {
try {
context._jar.setCookieSync(cookieString, url);
}
catch (err) {
debug(`Could not parse cookieString "${cookieString}" from response header, skipping it`);
debug(err);
ee.emit('error', 'cookie_parse_error_invalid_cookie');
}
});
}
}
ee.emit('counter', `http.codes.${code}`, 1);
ee.emit('counter', 'http.responses', 1);
// ee.emit('rate', 'http.response_rate');
ee.emit('histogram', 'http.response_time', res.timings.phases.firstByte);
const statusCode = res.statusCode;
if (statusCode >= 200 && statusCode < 300) {
ee.emit('histogram', 'http.response_time.2xx', res.timings.phases.firstByte);
}
else if (statusCode >= 300 && statusCode < 400) {
ee.emit('histogram', 'http.response_time.3xx', res.timings.phases.firstByte);
}
else if (statusCode >= 400 && statusCode < 500) {
ee.emit('histogram', 'http.response_time.4xx', res.timings.phases.firstByte);
}
else if (statusCode >= 500 && statusCode < 600) {
ee.emit('histogram', 'http.response_time.5xx', res.timings.phases.firstByte);
}
if (this.extendedHTTPMetrics) {
ee.emit('histogram', 'http.dns', res.timings.phases.dns);
ee.emit('histogram', 'http.tcp', res.timings.phases.tcp);
ee.emit('histogram', 'http.tls', res.timings.phases.tls);
}
let body = '';
if (responseProcessor) {
res.on('data', (d) => {
body += d;
});
}
else {
res.on('data', () => { });
}
res.on('end', () => {
if (this.extendedHTTPMetrics) {
ee.emit('histogram', 'http.total', res.timings.phases.total);
}
context._successCount++;
// config.defaults won't be taken into account for this
const isLastRequest = lastRequest(res, requestParams);
if (responseProcessor) {
responseProcessor(isLastRequest, res, body, (processResponseErr) => {
// capture/match returned an error object, or a hook function returned
// with an error
if (processResponseErr) {
return callback(processResponseErr, context);
}
if (isLastRequest) {
return callback(null, context);
}
});
}
else {
if (isLastRequest) {
return callback(null, context);
}
}
});
}
setInitialContext(initialContext) {
initialContext._successCount = 0;
initialContext._defaultStrictCapture = true;
if (this.config.http?.defaults?.strictCapture === false ||
this.config.defaults?.strictCapture === false) {
initialContext._defaultStrictCapture = false;
}
initialContext._jar = new tough.CookieJar(null, this.config.http.cookieJarOptions);
initialContext._enableCookieJar = false;
// If a default cookie is set, we will use the jar straightaway:
if (typeof this.config.http.defaults.cookie === 'object' ||
typeof this.config.defaults.cookie === 'object') {
initialContext._defaultCookie =
this.config.http.defaults.cookie || this.config.defaults.cookie;
initialContext._enableCookieJar = true;
}
if (this.config.http && typeof this.config.http.pool !== 'undefined') {
// Reuse common agents (created in the engine instance constructor)
initialContext._httpAgent = this._httpAgent;
initialContext._httpsAgent = this._httpsAgent;
}
else {
// Create agents just for this VU
const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
maxSockets: 1,
maxFreeSockets: 1
});
const agentTimeoutMs = deriveAgentTimeoutMs(this.config);
if (agentTimeoutMs !== undefined) {
agentOpts.timeout = agentTimeoutMs;
}
const agents = createAgents({
http: process.env.HTTP_PROXY,
https: process.env.HTTPS_PROXY
}, agentOpts);
initialContext._httpAgent = agents.httpAgent;
initialContext._httpsAgent = agents.httpsAgent;
}
return initialContext;
}
compile(tasks, _scenarioSpec, ee) {
const self = this;
return async function scenario(initialContext, callback) {
let context = self.setInitialContext(initialContext);
ee.emit('started');
for (const task of tasks) {
try {
context = (await promisify(task)(context));
}
catch (taskErr) {
const err = taskErr;
ee.emit('error', err.code || err.message);
if (callback) {
return callback(err, context);
}
throw taskErr;
}
}
if (callback) {
return callback(null, context);
}
return context;
};
}
}
function lastRequest(res, requestParams) {
// We're done when:
// - 3xx response and not following redirects
// - not a 3xx response
return ((res.statusCode >= 300 &&
res.statusCode < 400 &&
!requestParams.followRedirect) ||
res.statusCode < 300 ||
res.statusCode >= 400);
}
function maybePrependBase(uri, config) {
if (_.startsWith(uri, '/')) {
return config.target + uri;
}
else {
return uri;
}
}
/*
* Given a dictionary, return a dictionary with all keys lowercased.
*/
function lowcaseKeys(h) {
return _.transform((h || {}), (result, v, k) => {
result[String(k).toLowerCase()] = v;
});
}
function runOnErrorHooks(functionNames, functions, err, requestParams, context, ee, callback) {
async.eachSeries(functionNames, function iteratee(functionName, next) {
const processFunc = functions?.[functionName];
processFunc(err, requestParams, context, ee, (asyncErr) => {
if (asyncErr) {
return next(asyncErr);
}
return next(null);
});
}, function done(asyncErr) {
return callback(asyncErr);
});
}