@artilleryio/int-core 2.27.0 → 2.28.0-670d2fa
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/.turbo/turbo-build.log +4 -0
- package/dist/index.js +13 -0
- package/dist/lib/engine_http.js +817 -0
- package/dist/lib/engine_socketio.js +347 -0
- package/dist/lib/engine_ws.js +278 -0
- package/dist/lib/is-idle-phase.js +9 -0
- package/dist/lib/phases.js +213 -0
- package/dist/lib/readers.js +58 -0
- package/dist/lib/runner.js +463 -0
- package/dist/lib/ssms.js +555 -0
- package/dist/lib/update-global-object.js +75 -0
- package/dist/lib/weighted-pick.js +54 -0
- package/index.ts +14 -0
- package/lib/{engine_http.js → engine_http.ts} +28 -28
- package/lib/{engine_socketio.js → engine_socketio.ts} +13 -11
- package/lib/{engine_ws.js → engine_ws.ts} +16 -10
- package/lib/{is-idle-phase.js → is-idle-phase.ts} +1 -3
- package/lib/{phases.js → phases.ts} +12 -10
- package/lib/{readers.js → readers.ts} +2 -4
- package/lib/{runner.js → runner.ts} +84 -69
- package/lib/{ssms.js → ssms.ts} +18 -18
- package/{index.js → lib/update-global-object.ts} +9 -14
- package/lib/{weighted-pick.js → weighted-pick.ts} +7 -3
- package/package.json +10 -17
- package/tsconfig.build.json +9 -0
- package/types.d.ts +12 -0
|
@@ -0,0 +1,817 @@
|
|
|
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
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import qs from 'node:querystring';
|
|
8
|
+
import { parse as urlparse } from 'node:url';
|
|
9
|
+
import { callbackify, promisify } from 'node:util';
|
|
10
|
+
import { engine_util as engineUtil } from '@artilleryio/int-commons';
|
|
11
|
+
import HttpAgent from 'agentkeepalive';
|
|
12
|
+
import async from 'async';
|
|
13
|
+
import createDebug from 'debug';
|
|
14
|
+
import decompressResponse from 'decompress-response';
|
|
15
|
+
import filtrex from 'filtrex';
|
|
16
|
+
import FormData from 'form-data';
|
|
17
|
+
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
|
18
|
+
import _ from 'lodash';
|
|
19
|
+
import * as tough from 'tough-cookie';
|
|
20
|
+
const debug = createDebug('http');
|
|
21
|
+
const debugRequests = createDebug('http:request');
|
|
22
|
+
const debugResponse = createDebug('http:response');
|
|
23
|
+
const USER_AGENT = 'Artillery (https://artillery.io)';
|
|
24
|
+
const ensurePropertyIsAList = engineUtil.ensurePropertyIsAList;
|
|
25
|
+
const template = engineUtil.template;
|
|
26
|
+
const { HttpsAgent } = HttpAgent;
|
|
27
|
+
export default HttpEngine;
|
|
28
|
+
const GOT_OPTION_NAMES = [
|
|
29
|
+
'url',
|
|
30
|
+
'searchParams',
|
|
31
|
+
'method',
|
|
32
|
+
'headers',
|
|
33
|
+
'body',
|
|
34
|
+
'json',
|
|
35
|
+
'form',
|
|
36
|
+
'allowGetBody',
|
|
37
|
+
'timeout',
|
|
38
|
+
'retry',
|
|
39
|
+
'encoding',
|
|
40
|
+
'cookieJar',
|
|
41
|
+
'followRedirect',
|
|
42
|
+
'maxRedirects',
|
|
43
|
+
'decompress',
|
|
44
|
+
'http2',
|
|
45
|
+
'agent',
|
|
46
|
+
'username',
|
|
47
|
+
'password',
|
|
48
|
+
'https',
|
|
49
|
+
'throwHttpErrors'
|
|
50
|
+
];
|
|
51
|
+
const DEFAULT_AGENT_OPTIONS = {
|
|
52
|
+
keepAlive: true,
|
|
53
|
+
keepAliveMsec: 1000
|
|
54
|
+
};
|
|
55
|
+
// agentkeepalive defaults `timeout` (active socket inactivity) to a hardcoded
|
|
56
|
+
// floor of 8000ms (Math.max(freeSocketTimeout * 2, 8000)). When a request's
|
|
57
|
+
// origin doesn't send response bytes within 8s, the socket is destroyed with
|
|
58
|
+
// `error.code = 'ERR_SOCKET_TIMEOUT'` regardless of the user's configured
|
|
59
|
+
// `http.timeout`. Mirror `http.timeout` (or top-level `timeout`) into the agent
|
|
60
|
+
// so the YAML config becomes the actual binding constraint.
|
|
61
|
+
function deriveAgentTimeoutMs(scriptConfig) {
|
|
62
|
+
const timeoutSec = scriptConfig?.timeout || scriptConfig?.http?.timeout;
|
|
63
|
+
if (typeof timeoutSec !== 'number')
|
|
64
|
+
return undefined;
|
|
65
|
+
// Never go below the existing 8s floor — preserves current behaviour for
|
|
66
|
+
// users who explicitly configure a smaller http.timeout.
|
|
67
|
+
return Math.max(8000, timeoutSec * 1000);
|
|
68
|
+
}
|
|
69
|
+
function createAgents(proxies, opts) {
|
|
70
|
+
const agentOpts = Object.assign({}, DEFAULT_AGENT_OPTIONS, opts);
|
|
71
|
+
const result = {
|
|
72
|
+
httpAgent: null,
|
|
73
|
+
httpsAgent: null
|
|
74
|
+
};
|
|
75
|
+
// HTTP proxy endpoint will be used for all requests, unless a separate
|
|
76
|
+
// HTTPS proxy URL is also set, which will be used for HTTPS requests:
|
|
77
|
+
if (proxies.http) {
|
|
78
|
+
agentOpts.proxy = proxies.http;
|
|
79
|
+
result.httpAgent = new HttpProxyAgent(agentOpts);
|
|
80
|
+
if (proxies.https) {
|
|
81
|
+
agentOpts.proxy = proxies.https;
|
|
82
|
+
}
|
|
83
|
+
result.httpsAgent = new HttpsProxyAgent(agentOpts);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
// If only HTTPS proxy is provided, it will be used for HTTPS requests,
|
|
87
|
+
// but not for HTTP requests:
|
|
88
|
+
if (proxies.https) {
|
|
89
|
+
result.httpAgent = new HttpAgent(agentOpts);
|
|
90
|
+
result.httpsAgent = new HttpsProxyAgent(Object.assign({ proxy: proxies.https }, agentOpts));
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
// By default nothing is proxied:
|
|
94
|
+
result.httpAgent = new HttpAgent(agentOpts);
|
|
95
|
+
result.httpsAgent = new HttpsAgent(agentOpts);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
function HttpEngine(script) {
|
|
99
|
+
this.config = script.config;
|
|
100
|
+
if (typeof this.config.defaults === 'undefined') {
|
|
101
|
+
this.config.defaults = {};
|
|
102
|
+
}
|
|
103
|
+
if (typeof this.config.http === 'undefined') {
|
|
104
|
+
this.config.http = {};
|
|
105
|
+
}
|
|
106
|
+
if (typeof this.config.http.defaults === 'undefined') {
|
|
107
|
+
this.config.http.defaults = {};
|
|
108
|
+
}
|
|
109
|
+
if (typeof this.config.http.cookieJarOptions === 'undefined') {
|
|
110
|
+
this.config.http.cookieJarOptions = {};
|
|
111
|
+
}
|
|
112
|
+
// If config.http.pool is set, create & reuse agents for all requests (with
|
|
113
|
+
// max sockets set). That's what we're done here.
|
|
114
|
+
// If config.http.pool is not set, we create new agents for each virtual user.
|
|
115
|
+
// That's done when the VU is initialized.
|
|
116
|
+
this.maxSockets = Infinity;
|
|
117
|
+
if (script.config.http?.pool) {
|
|
118
|
+
this.maxSockets = Number(script.config.http.pool);
|
|
119
|
+
}
|
|
120
|
+
const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
|
|
121
|
+
maxSockets: this.maxSockets,
|
|
122
|
+
maxFreeSockets: this.maxSockets
|
|
123
|
+
});
|
|
124
|
+
const agentTimeoutMs = deriveAgentTimeoutMs(script.config);
|
|
125
|
+
if (agentTimeoutMs !== undefined) {
|
|
126
|
+
agentOpts.timeout = agentTimeoutMs;
|
|
127
|
+
}
|
|
128
|
+
const agents = createAgents({
|
|
129
|
+
http: process.env.HTTP_PROXY,
|
|
130
|
+
https: process.env.HTTPS_PROXY
|
|
131
|
+
}, agentOpts);
|
|
132
|
+
this._httpAgent = agents.httpAgent;
|
|
133
|
+
this._httpsAgent = agents.httpsAgent;
|
|
134
|
+
if ((script.config.http && script.config.http.extendedMetrics === true) ||
|
|
135
|
+
global.artillery?.runtimeOptions.extendedHTTPMetrics) {
|
|
136
|
+
this.extendedHTTPMetrics = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
HttpEngine.prototype.init = async function () {
|
|
140
|
+
this.request = (await import('got')).default;
|
|
141
|
+
};
|
|
142
|
+
HttpEngine.prototype._isDistributedTracingEnabled = (config) => {
|
|
143
|
+
const dtConfig = config.http?.distributedTracing;
|
|
144
|
+
if (!dtConfig) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
// Handle both boolean and object forms
|
|
148
|
+
if (typeof dtConfig === 'boolean') {
|
|
149
|
+
return dtConfig;
|
|
150
|
+
}
|
|
151
|
+
if (typeof dtConfig === 'object' && dtConfig.enabled !== undefined) {
|
|
152
|
+
return dtConfig.enabled;
|
|
153
|
+
}
|
|
154
|
+
// Default to true if distributedTracing is set but enabled is not specified
|
|
155
|
+
return true;
|
|
156
|
+
};
|
|
157
|
+
HttpEngine.prototype._generateTraceparent = (config) => {
|
|
158
|
+
// W3C Trace Context format: version-trace-id-parent-id-trace-flags
|
|
159
|
+
const version = '00';
|
|
160
|
+
// Get configuration
|
|
161
|
+
const dtConfig = config.http?.distributedTracing;
|
|
162
|
+
let sampled = true; // Default to sampled
|
|
163
|
+
let traceIdPrefix = 'a9'; // Default prefix
|
|
164
|
+
if (typeof dtConfig === 'object') {
|
|
165
|
+
if (dtConfig.sampled !== undefined) {
|
|
166
|
+
sampled = dtConfig.sampled;
|
|
167
|
+
}
|
|
168
|
+
if (dtConfig.traceIdPrefix !== undefined) {
|
|
169
|
+
traceIdPrefix = dtConfig.traceIdPrefix;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Validate and normalize prefix (must be valid hex, max 8 chars)
|
|
173
|
+
traceIdPrefix = traceIdPrefix.toLowerCase().replace(/[^0-9a-f]/g, '').slice(0, 8);
|
|
174
|
+
if (traceIdPrefix.length === 0) {
|
|
175
|
+
traceIdPrefix = 'a9'; // Fallback to default if invalid
|
|
176
|
+
}
|
|
177
|
+
// Generate trace-id with prefix (32 hex chars total)
|
|
178
|
+
const remainingBytes = Math.ceil((32 - traceIdPrefix.length) / 2);
|
|
179
|
+
const randomPart = crypto.randomBytes(remainingBytes).toString('hex');
|
|
180
|
+
const traceId = (traceIdPrefix + randomPart).slice(0, 32);
|
|
181
|
+
// Generate 8-byte parent-id (16 hex chars)
|
|
182
|
+
const parentId = crypto.randomBytes(8).toString('hex');
|
|
183
|
+
const traceFlags = sampled ? '01' : '00';
|
|
184
|
+
return `${version}-${traceId}-${parentId}-${traceFlags}`;
|
|
185
|
+
};
|
|
186
|
+
HttpEngine.prototype.createScenario = function (scenarioSpec, ee) {
|
|
187
|
+
ensurePropertyIsAList(scenarioSpec, 'beforeRequest');
|
|
188
|
+
ensurePropertyIsAList(scenarioSpec, 'afterResponse');
|
|
189
|
+
ensurePropertyIsAList(scenarioSpec, 'beforeScenario');
|
|
190
|
+
ensurePropertyIsAList(scenarioSpec, 'afterScenario');
|
|
191
|
+
ensurePropertyIsAList(scenarioSpec, 'onError');
|
|
192
|
+
// Add scenario-level hooks if needed:
|
|
193
|
+
// For now, just turn them into function steps and insert them
|
|
194
|
+
// directly into the flow array.
|
|
195
|
+
// TODO: Scenario-level hooks will probably want access to the
|
|
196
|
+
// entire scenario spec rather than just the userContext.
|
|
197
|
+
const beforeScenarioFns = _.map(scenarioSpec.beforeScenario, (hookFunctionName) => ({ function: hookFunctionName }));
|
|
198
|
+
const afterScenarioFns = _.map(scenarioSpec.afterScenario, (hookFunctionName) => ({ function: hookFunctionName }));
|
|
199
|
+
const newFlow = beforeScenarioFns.concat(scenarioSpec.flow.concat(afterScenarioFns));
|
|
200
|
+
scenarioSpec.flow = newFlow;
|
|
201
|
+
const tasks = _.map(scenarioSpec.flow, (rs) => this.step(rs, ee, {
|
|
202
|
+
beforeRequest: scenarioSpec.beforeRequest,
|
|
203
|
+
afterResponse: scenarioSpec.afterResponse,
|
|
204
|
+
onError: scenarioSpec.onError
|
|
205
|
+
}));
|
|
206
|
+
return this.compile(tasks, scenarioSpec, ee);
|
|
207
|
+
};
|
|
208
|
+
HttpEngine.prototype.step = function step(requestSpec, ee, opts) {
|
|
209
|
+
opts = opts || {};
|
|
210
|
+
const self = this;
|
|
211
|
+
const config = this.config;
|
|
212
|
+
if (requestSpec.loop) {
|
|
213
|
+
const steps = _.map(requestSpec.loop, (rs) => self.step(rs, ee, opts));
|
|
214
|
+
return engineUtil.createLoopWithCount(requestSpec.count || -1, steps, {
|
|
215
|
+
loopValue: requestSpec.loopValue || '$loopCount',
|
|
216
|
+
loopElement: requestSpec.loopElement || '$loopElement',
|
|
217
|
+
overValues: requestSpec.over,
|
|
218
|
+
whileTrue: self.config.processor
|
|
219
|
+
? self.config.processor[requestSpec.whileTrue]
|
|
220
|
+
: undefined
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (requestSpec.parallel) {
|
|
224
|
+
const steps = _.map(requestSpec.parallel, (rs) => self.step(rs, ee, opts));
|
|
225
|
+
return engineUtil.createParallel(steps, {
|
|
226
|
+
limitValue: requestSpec.limit
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (typeof requestSpec.think !== 'undefined') {
|
|
230
|
+
return engineUtil.createThink(requestSpec, self.config.http.defaults.think || self.config.defaults.think);
|
|
231
|
+
}
|
|
232
|
+
if (typeof requestSpec.log !== 'undefined') {
|
|
233
|
+
return (context, callback) => {
|
|
234
|
+
console.log(template(requestSpec.log, context));
|
|
235
|
+
return process.nextTick(() => {
|
|
236
|
+
callback(null, context);
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (requestSpec.function) {
|
|
241
|
+
return (context, callback) => {
|
|
242
|
+
const processFunc = self.config.processor[requestSpec.function];
|
|
243
|
+
if (processFunc) {
|
|
244
|
+
let f;
|
|
245
|
+
if (processFunc.constructor.name === 'Function') {
|
|
246
|
+
f = processFunc;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
f = callbackify(processFunc);
|
|
250
|
+
}
|
|
251
|
+
return f(context, ee, (hookErr) => callback(hookErr, context));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
debug(`Function "${requestSpec.function}" not defined`);
|
|
255
|
+
debug('processor: %o', self.config.processor);
|
|
256
|
+
ee.emit('error', `Undefined function "${requestSpec.function}"`);
|
|
257
|
+
return process.nextTick(() => {
|
|
258
|
+
callback(null, context);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const f = (context, callback) => {
|
|
264
|
+
const method = _.keys(requestSpec)[0].toUpperCase();
|
|
265
|
+
const params = requestSpec[method.toLowerCase()];
|
|
266
|
+
const onErrorHandlers = opts.onError; // only scenario-lever onError handlers are supported
|
|
267
|
+
// A special case for when "url" attribute is missing. We need to check for
|
|
268
|
+
// it manually as request.js won't emit an 'error' event when the argument
|
|
269
|
+
// is missing.
|
|
270
|
+
// This will be obsoleted by better script validation.
|
|
271
|
+
if (!params.url && !params.uri) {
|
|
272
|
+
const err = new Error('an URL must be specified');
|
|
273
|
+
return callback(err, context);
|
|
274
|
+
}
|
|
275
|
+
const tls = config.tls || {};
|
|
276
|
+
const timeout = config.timeout || _.get(config, 'http.timeout') || 10;
|
|
277
|
+
if (!engineUtil.isProbableEnough(params)) {
|
|
278
|
+
return process.nextTick(() => {
|
|
279
|
+
callback(null, context);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (!_.isUndefined(params.ifTrue)) {
|
|
283
|
+
let result;
|
|
284
|
+
try {
|
|
285
|
+
const cond = _.has(config.processor, params.ifTrue)
|
|
286
|
+
? config.processor[params.ifTrue]
|
|
287
|
+
: filtrex(params.ifTrue);
|
|
288
|
+
result = cond(context.vars);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
debug('ifTrue error:', err);
|
|
292
|
+
result = 1; // if the expression is incorrect, just proceed
|
|
293
|
+
}
|
|
294
|
+
if (!result) {
|
|
295
|
+
return process.nextTick(() => {
|
|
296
|
+
callback(null, context);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Run beforeRequest processors (scenario-level ones too)
|
|
301
|
+
const requestParams = _.extend(_.clone(params), {
|
|
302
|
+
url: maybePrependBase(params.url || params.uri, config), // *NOT* templating here
|
|
303
|
+
method: method,
|
|
304
|
+
timeout: timeout,
|
|
305
|
+
uuid: crypto.randomUUID()
|
|
306
|
+
});
|
|
307
|
+
if (context._enableCookieJar) {
|
|
308
|
+
requestParams.cookieJar = context._jar;
|
|
309
|
+
}
|
|
310
|
+
if (tls) {
|
|
311
|
+
requestParams.https = requestParams.https || {};
|
|
312
|
+
requestParams.https = _.extend(requestParams.https, tls);
|
|
313
|
+
}
|
|
314
|
+
const functionNames = _.concat(opts.beforeRequest || [], params.beforeRequest || []);
|
|
315
|
+
async.eachSeries(functionNames, function iteratee(functionName, next) {
|
|
316
|
+
const fn = template(functionName, context);
|
|
317
|
+
let processFunc = config.processor[fn];
|
|
318
|
+
if (!processFunc) {
|
|
319
|
+
processFunc = (_r, _c, _e, cb) => cb(null);
|
|
320
|
+
console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
|
|
321
|
+
}
|
|
322
|
+
if (processFunc.constructor.name === 'Function') {
|
|
323
|
+
processFunc(requestParams, context, ee, (err) => {
|
|
324
|
+
if (err) {
|
|
325
|
+
return next(err);
|
|
326
|
+
}
|
|
327
|
+
return next(null);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
processFunc(requestParams, context, ee).then(next).catch(next);
|
|
332
|
+
}
|
|
333
|
+
}, function done(err) {
|
|
334
|
+
if (err) {
|
|
335
|
+
debug(err);
|
|
336
|
+
return callback(err, context);
|
|
337
|
+
}
|
|
338
|
+
// Order of precedence: json set in a function, json set in the script, body set in a function, body set in the script.
|
|
339
|
+
if (requestParams.json) {
|
|
340
|
+
requestParams.json = template(requestParams.json, context);
|
|
341
|
+
delete requestParams.body;
|
|
342
|
+
}
|
|
343
|
+
else if (requestParams.body) {
|
|
344
|
+
requestParams.body = template(requestParams.body, context);
|
|
345
|
+
// TODO: Warn if body is not a string or a buffer
|
|
346
|
+
}
|
|
347
|
+
// add loop, name & uri elements to be interpolated
|
|
348
|
+
if (context.vars.$loopElement) {
|
|
349
|
+
context.vars.$loopElement = template(context.vars.$loopElement, context);
|
|
350
|
+
}
|
|
351
|
+
if (requestParams.name) {
|
|
352
|
+
requestParams.name = template(requestParams.name, context);
|
|
353
|
+
}
|
|
354
|
+
if (requestParams.uri) {
|
|
355
|
+
requestParams.uri = template(requestParams.uri, context);
|
|
356
|
+
}
|
|
357
|
+
if (requestParams.url) {
|
|
358
|
+
requestParams.url = template(requestParams.url, context);
|
|
359
|
+
}
|
|
360
|
+
// Follow all redirects by default unless specified otherwise
|
|
361
|
+
if (typeof requestParams.followRedirect === 'undefined') {
|
|
362
|
+
requestParams.followRedirect = true;
|
|
363
|
+
}
|
|
364
|
+
// TODO: Use traverse on the entire flow instead
|
|
365
|
+
// Request.js -> Got.js translation
|
|
366
|
+
if (params.qs) {
|
|
367
|
+
requestParams.searchParams = qs.stringify(template(params.qs, context));
|
|
368
|
+
}
|
|
369
|
+
if (typeof params.gzip === 'boolean') {
|
|
370
|
+
requestParams.decompress = params.gzip;
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
requestParams.decompress = true;
|
|
374
|
+
}
|
|
375
|
+
if (params.form) {
|
|
376
|
+
requestParams.form = _.reduce(requestParams.form, (acc, v, k) => {
|
|
377
|
+
acc[k] = template(v, context);
|
|
378
|
+
return acc;
|
|
379
|
+
}, {});
|
|
380
|
+
}
|
|
381
|
+
if (params.formData) {
|
|
382
|
+
let fileUpload;
|
|
383
|
+
const f = new FormData();
|
|
384
|
+
requestParams.body = _.reduce(requestParams.formData, (acc, v, k) => {
|
|
385
|
+
let V = template(v, context);
|
|
386
|
+
let options;
|
|
387
|
+
if (V && _.isPlainObject(V)) {
|
|
388
|
+
if (V.contentType) {
|
|
389
|
+
options = { contentType: V.contentType };
|
|
390
|
+
}
|
|
391
|
+
if (V.fromFile) {
|
|
392
|
+
const absPath = path.resolve(path.dirname(context.vars.$scenarioFile), V.fromFile);
|
|
393
|
+
fileUpload = absPath;
|
|
394
|
+
V = fs.createReadStream(absPath);
|
|
395
|
+
}
|
|
396
|
+
else if (V.value) {
|
|
397
|
+
V = V.value;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
acc.append(k, V, options);
|
|
401
|
+
return acc;
|
|
402
|
+
}, f);
|
|
403
|
+
if (params.setContentLengthHeader && fileUpload) {
|
|
404
|
+
try {
|
|
405
|
+
requestParams.headers = requestParams.headers || {};
|
|
406
|
+
requestParams.headers['content-length'] =
|
|
407
|
+
fs.statSync(fileUpload).size;
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
debug(`stat() on ${fileUpload} failed with ${err}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// Assign default headers then overwrite as needed
|
|
415
|
+
const defaultHeaders = lowcaseKeys(config.http.defaults.headers ||
|
|
416
|
+
config.defaults.headers || { 'user-agent': USER_AGENT });
|
|
417
|
+
const combinedHeaders = _.extend(defaultHeaders, lowcaseKeys(params.headers), lowcaseKeys(requestParams.headers));
|
|
418
|
+
const templatedHeaders = _.mapValues(combinedHeaders, (v, _k, _obj) => template(v, context));
|
|
419
|
+
requestParams.headers = templatedHeaders;
|
|
420
|
+
// We compute the url here so that the cookies are set properly afterwards
|
|
421
|
+
const url = maybePrependBase(template(requestParams.uri || requestParams.url, context), config);
|
|
422
|
+
if (requestParams.uri) {
|
|
423
|
+
// If a hook function sets requestParams.uri to something, request.js
|
|
424
|
+
// will pick that over .url, so we need to delete it.
|
|
425
|
+
delete requestParams.uri;
|
|
426
|
+
}
|
|
427
|
+
requestParams.url = url;
|
|
428
|
+
if (typeof requestParams.cookie === 'object' ||
|
|
429
|
+
typeof context._defaultCookie === 'object') {
|
|
430
|
+
requestParams.cookieJar = context._jar;
|
|
431
|
+
const cookie = Object.assign({}, context._defaultCookie, requestParams.cookie);
|
|
432
|
+
Object.keys(cookie).forEach((k) => {
|
|
433
|
+
context._jar.setCookieSync(`${k}=${template(cookie[k], context)}`, requestParams.url);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (typeof requestParams.auth === 'object') {
|
|
437
|
+
requestParams.username = template(requestParams.auth.user, context);
|
|
438
|
+
requestParams.password = template(requestParams.auth.pass, context);
|
|
439
|
+
delete requestParams.auth;
|
|
440
|
+
}
|
|
441
|
+
// TODO: Bypass proxy if "proxy: false" is set
|
|
442
|
+
requestParams.agent = {
|
|
443
|
+
http: context._httpAgent,
|
|
444
|
+
https: context._httpsAgent
|
|
445
|
+
};
|
|
446
|
+
requestParams.throwHttpErrors = false;
|
|
447
|
+
if (!requestParams.url || !requestParams.url.startsWith('http')) {
|
|
448
|
+
const err = new Error(`Invalid URL - ${requestParams.url}`);
|
|
449
|
+
return callback(err, context);
|
|
450
|
+
}
|
|
451
|
+
function responseProcessor(isLast, res, body, done) {
|
|
452
|
+
if (process.env.DEBUG) {
|
|
453
|
+
let requestInfo = {
|
|
454
|
+
url: requestParams.url,
|
|
455
|
+
method: requestParams.method,
|
|
456
|
+
headers: requestParams.headers
|
|
457
|
+
};
|
|
458
|
+
if (context._jar._jar &&
|
|
459
|
+
typeof context._jar._jar.getCookieStringSync === 'function') {
|
|
460
|
+
requestInfo = Object.assign(requestInfo, {
|
|
461
|
+
cookie: context._jar._jar.getCookieStringSync(requestParams.url)
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if (requestParams.json && typeof requestParams.json !== 'boolean') {
|
|
465
|
+
requestInfo.json = requestParams.json;
|
|
466
|
+
}
|
|
467
|
+
// 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.
|
|
468
|
+
if (requestParams.body && typeof requestParams.json !== 'object') {
|
|
469
|
+
if (process.env.DEBUG.indexOf('http:full_body') > -1) {
|
|
470
|
+
// Show the entire body
|
|
471
|
+
requestInfo.body = requestParams.body;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
// Only show the beginning of long bodies
|
|
475
|
+
if (typeof requestParams.body === 'string') {
|
|
476
|
+
requestInfo.body = requestParams.body.substring(0, 512);
|
|
477
|
+
if (requestParams.body.length > 512) {
|
|
478
|
+
requestInfo.body += ' ...';
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (typeof requestParams.body === 'object') {
|
|
482
|
+
requestInfo.body = `< ${requestParams.body.constructor.name} >`;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
requestInfo.body = String(requestInfo.body);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (requestParams.qs) {
|
|
490
|
+
requestInfo.qs = qs.encode(Object.assign(qs.parse(urlparse(requestParams.url).query), template(requestParams.qs, context)));
|
|
491
|
+
}
|
|
492
|
+
debug('request: %s', JSON.stringify(requestInfo, null, 2));
|
|
493
|
+
}
|
|
494
|
+
debugResponse(JSON.stringify(res.headers, null, 2));
|
|
495
|
+
debugResponse(JSON.stringify(body, null, 2));
|
|
496
|
+
// capture/match/response hooks run only for last request in a task
|
|
497
|
+
if (!isLast) {
|
|
498
|
+
return done(null, context);
|
|
499
|
+
}
|
|
500
|
+
const resForCapture = { headers: res.headers, body: body };
|
|
501
|
+
engineUtil.captureOrMatch(params, resForCapture, context, function captured(err, result) {
|
|
502
|
+
if (err) {
|
|
503
|
+
// Run onError hooks and end the scenario:
|
|
504
|
+
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => done(err, context));
|
|
505
|
+
}
|
|
506
|
+
let haveFailedMatches = false;
|
|
507
|
+
let haveFailedCaptures = false;
|
|
508
|
+
if (result !== null) {
|
|
509
|
+
ee.emit('trace:http:capture', result, requestParams.uuid);
|
|
510
|
+
if (Object.keys(result.matches).length > 0 ||
|
|
511
|
+
Object.keys(result.captures).length > 0) {
|
|
512
|
+
debug('captures and matches:');
|
|
513
|
+
debug(result.matches);
|
|
514
|
+
debug(result.captures);
|
|
515
|
+
}
|
|
516
|
+
// match and capture are strict by default:
|
|
517
|
+
haveFailedMatches = _.some(result.matches, (v, _k) => !v.success && v.strict !== false);
|
|
518
|
+
haveFailedCaptures = _.some(result.captures, (v, _k) => v.failed);
|
|
519
|
+
if (haveFailedMatches || haveFailedCaptures) {
|
|
520
|
+
// TODO: Emit the details of each failed capture/match
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
_.each(result.matches, (v, _k) => {
|
|
524
|
+
ee.emit('match', v.success, {
|
|
525
|
+
expected: v.expected,
|
|
526
|
+
got: v.got,
|
|
527
|
+
expression: v.expression,
|
|
528
|
+
strict: v.strict
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
_.each(result.captures, (v, k) => {
|
|
532
|
+
_.set(context.vars, k, v.value);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Now run afterResponse processors
|
|
537
|
+
const functionNames = _.concat(opts.afterResponse || [], params.afterResponse || []);
|
|
538
|
+
async.eachSeries(functionNames, function iteratee(functionName, next) {
|
|
539
|
+
const fn = template(functionName, context);
|
|
540
|
+
let processFunc = config.processor[fn];
|
|
541
|
+
if (!processFunc) {
|
|
542
|
+
// TODO: DRY - #223
|
|
543
|
+
processFunc = (_r, _res, _c, _e, cb) => cb(null);
|
|
544
|
+
console.log(`WARNING: custom function ${fn} could not be found`); // TODO: a 'warning' event
|
|
545
|
+
}
|
|
546
|
+
// Got does not have res.body which Request.js used to have, so we attach it here:
|
|
547
|
+
res.body = body;
|
|
548
|
+
if (processFunc.constructor.name === 'Function') {
|
|
549
|
+
processFunc(requestParams, res, context, ee, (err) => {
|
|
550
|
+
if (err) {
|
|
551
|
+
return next(err);
|
|
552
|
+
}
|
|
553
|
+
return next(null);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
processFunc(requestParams, res, context, ee)
|
|
558
|
+
.then(next)
|
|
559
|
+
.catch(next);
|
|
560
|
+
}
|
|
561
|
+
}, (err) => {
|
|
562
|
+
if (err) {
|
|
563
|
+
debug(err);
|
|
564
|
+
return done(err, context);
|
|
565
|
+
}
|
|
566
|
+
if (haveFailedMatches || haveFailedCaptures) {
|
|
567
|
+
// FIXME: This means only one error in the report even if multiple captures failed for the same request.
|
|
568
|
+
return done(new Error('Failed capture or match'), context);
|
|
569
|
+
}
|
|
570
|
+
return done(null, context);
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
let needToProcessResponse = false;
|
|
575
|
+
if (typeof requestParams.capture === 'object' ||
|
|
576
|
+
typeof requestParams.match === 'object' ||
|
|
577
|
+
requestParams.afterResponse ||
|
|
578
|
+
(typeof opts.afterResponse === 'object' &&
|
|
579
|
+
opts.afterResponse.length > 0) ||
|
|
580
|
+
process.env.DEBUG) {
|
|
581
|
+
needToProcessResponse = true;
|
|
582
|
+
}
|
|
583
|
+
if (!requestParams.url) {
|
|
584
|
+
const err = new Error('an URL must be specified');
|
|
585
|
+
// Run onError hooks and end the scenario
|
|
586
|
+
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
|
|
587
|
+
}
|
|
588
|
+
requestParams.retry = { limit: 0 }; // disable retries - ignored when using streams
|
|
589
|
+
// Convert scalar seconds to Got v14 timeout object right before request
|
|
590
|
+
const gotOptions = _.pick(requestParams, GOT_OPTION_NAMES);
|
|
591
|
+
gotOptions.timeout = { response: requestParams.timeout * 1000 };
|
|
592
|
+
// Add W3C Trace Context headers if distributed tracing is enabled
|
|
593
|
+
if (self._isDistributedTracingEnabled(config)) {
|
|
594
|
+
const traceparent = self._generateTraceparent(config);
|
|
595
|
+
gotOptions.headers = gotOptions.headers || {};
|
|
596
|
+
gotOptions.headers.traceparent = traceparent;
|
|
597
|
+
}
|
|
598
|
+
let totalDownloaded = 0;
|
|
599
|
+
self
|
|
600
|
+
.request(gotOptions)
|
|
601
|
+
.on('request', (req) => {
|
|
602
|
+
ee.emit('trace:http:request', requestParams, requestParams.uuid);
|
|
603
|
+
debugRequests('request start: %s', req.path);
|
|
604
|
+
ee.emit('counter', 'http.requests', 1);
|
|
605
|
+
ee.emit('rate', 'http.request_rate');
|
|
606
|
+
req.on('response', (res) => {
|
|
607
|
+
res.on('end', () => {
|
|
608
|
+
ee.emit('counter', 'http.downloaded_bytes', totalDownloaded);
|
|
609
|
+
});
|
|
610
|
+
ee.emit('trace:http:response', res, requestParams.uuid);
|
|
611
|
+
self._handleResponse(requestParams, res, ee, context, needToProcessResponse ? responseProcessor : null, callback);
|
|
612
|
+
});
|
|
613
|
+
})
|
|
614
|
+
.on('downloadProgress', (progress) => {
|
|
615
|
+
totalDownloaded = progress.total;
|
|
616
|
+
})
|
|
617
|
+
.on('error', (err, _body, _res) => {
|
|
618
|
+
ee.emit('trace:http:error', err, requestParams.uuid);
|
|
619
|
+
if (err.name === 'HTTPError') {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// this is an ENOTFOUND, ECONNRESET etc
|
|
623
|
+
debug(err);
|
|
624
|
+
// Run onError hooks and end the scenario:
|
|
625
|
+
runOnErrorHooks(onErrorHandlers, config.processor, err, requestParams, context, ee, (_asyncErr) => callback(err, context));
|
|
626
|
+
})
|
|
627
|
+
.catch((gotErr) => {
|
|
628
|
+
// TODO: Handle the error properly with run hooks
|
|
629
|
+
debug(gotErr);
|
|
630
|
+
runOnErrorHooks(onErrorHandlers, config.processor, gotErr, requestParams, context, ee, (_asyncErr) => callback(gotErr, context));
|
|
631
|
+
});
|
|
632
|
+
}); // eachSeries
|
|
633
|
+
};
|
|
634
|
+
return f;
|
|
635
|
+
};
|
|
636
|
+
HttpEngine.prototype._handleResponse = function (requestParams, res, ee, context, responseProcessor, callback) {
|
|
637
|
+
const url = requestParams.url;
|
|
638
|
+
if (requestParams.decompress) {
|
|
639
|
+
res = decompressResponse(res);
|
|
640
|
+
}
|
|
641
|
+
const code = res.statusCode;
|
|
642
|
+
if (!context._enableCookieJar) {
|
|
643
|
+
const rawCookies = res.headers['set-cookie'];
|
|
644
|
+
if (rawCookies) {
|
|
645
|
+
context._enableCookieJar = true;
|
|
646
|
+
rawCookies.forEach((cookieString) => {
|
|
647
|
+
try {
|
|
648
|
+
context._jar.setCookieSync(cookieString, url);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
debug(`Could not parse cookieString "${cookieString}" from response header, skipping it`);
|
|
652
|
+
debug(err);
|
|
653
|
+
ee.emit('error', 'cookie_parse_error_invalid_cookie');
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
ee.emit('counter', `http.codes.${code}`, 1);
|
|
659
|
+
ee.emit('counter', 'http.responses', 1);
|
|
660
|
+
// ee.emit('rate', 'http.response_rate');
|
|
661
|
+
ee.emit('histogram', 'http.response_time', res.timings.phases.firstByte);
|
|
662
|
+
const statusCode = res.statusCode;
|
|
663
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
664
|
+
ee.emit('histogram', 'http.response_time.2xx', res.timings.phases.firstByte);
|
|
665
|
+
}
|
|
666
|
+
else if (statusCode >= 300 && statusCode < 400) {
|
|
667
|
+
ee.emit('histogram', 'http.response_time.3xx', res.timings.phases.firstByte);
|
|
668
|
+
}
|
|
669
|
+
else if (statusCode >= 400 && statusCode < 500) {
|
|
670
|
+
ee.emit('histogram', 'http.response_time.4xx', res.timings.phases.firstByte);
|
|
671
|
+
}
|
|
672
|
+
else if (statusCode >= 500 && statusCode < 600) {
|
|
673
|
+
ee.emit('histogram', 'http.response_time.5xx', res.timings.phases.firstByte);
|
|
674
|
+
}
|
|
675
|
+
if (this.extendedHTTPMetrics) {
|
|
676
|
+
ee.emit('histogram', 'http.dns', res.timings.phases.dns);
|
|
677
|
+
ee.emit('histogram', 'http.tcp', res.timings.phases.tcp);
|
|
678
|
+
ee.emit('histogram', 'http.tls', res.timings.phases.tls);
|
|
679
|
+
}
|
|
680
|
+
let body = '';
|
|
681
|
+
if (responseProcessor) {
|
|
682
|
+
res.on('data', (d) => {
|
|
683
|
+
body += d;
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
res.on('data', () => { });
|
|
688
|
+
}
|
|
689
|
+
res.on('end', () => {
|
|
690
|
+
if (this.extendedHTTPMetrics) {
|
|
691
|
+
ee.emit('histogram', 'http.total', res.timings.phases.total);
|
|
692
|
+
}
|
|
693
|
+
context._successCount++;
|
|
694
|
+
// config.defaults won't be taken into account for this
|
|
695
|
+
const isLastRequest = lastRequest(res, requestParams);
|
|
696
|
+
if (responseProcessor) {
|
|
697
|
+
responseProcessor(isLastRequest, res, body, (processResponseErr) => {
|
|
698
|
+
// capture/match returned an error object, or a hook function returned
|
|
699
|
+
// with an error
|
|
700
|
+
if (processResponseErr) {
|
|
701
|
+
return callback(processResponseErr, context);
|
|
702
|
+
}
|
|
703
|
+
if (isLastRequest) {
|
|
704
|
+
return callback(null, context);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
if (isLastRequest) {
|
|
710
|
+
return callback(null, context);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
};
|
|
715
|
+
function lastRequest(res, requestParams) {
|
|
716
|
+
// We're done when:
|
|
717
|
+
// - 3xx response and not following redirects
|
|
718
|
+
// - not a 3xx response
|
|
719
|
+
return ((res.statusCode >= 300 &&
|
|
720
|
+
res.statusCode < 400 &&
|
|
721
|
+
!requestParams.followRedirect) ||
|
|
722
|
+
res.statusCode < 300 ||
|
|
723
|
+
res.statusCode >= 400);
|
|
724
|
+
}
|
|
725
|
+
HttpEngine.prototype.setInitialContext = function (initialContext) {
|
|
726
|
+
initialContext._successCount = 0;
|
|
727
|
+
initialContext._defaultStrictCapture = true;
|
|
728
|
+
if (this.config.http?.defaults?.strictCapture === false ||
|
|
729
|
+
this.config.defaults?.strictCapture === false) {
|
|
730
|
+
initialContext._defaultStrictCapture = false;
|
|
731
|
+
}
|
|
732
|
+
initialContext._jar = new tough.CookieJar(null, this.config.http.cookieJarOptions);
|
|
733
|
+
initialContext._enableCookieJar = false;
|
|
734
|
+
// If a default cookie is set, we will use the jar straightaway:
|
|
735
|
+
if (typeof this.config.http.defaults.cookie === 'object' ||
|
|
736
|
+
typeof this.config.defaults.cookie === 'object') {
|
|
737
|
+
initialContext._defaultCookie =
|
|
738
|
+
this.config.http.defaults.cookie || this.config.defaults.cookie;
|
|
739
|
+
initialContext._enableCookieJar = true;
|
|
740
|
+
}
|
|
741
|
+
if (this.config.http && typeof this.config.http.pool !== 'undefined') {
|
|
742
|
+
// Reuse common agents (created in the engine instance constructor)
|
|
743
|
+
initialContext._httpAgent = this._httpAgent;
|
|
744
|
+
initialContext._httpsAgent = this._httpsAgent;
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
// Create agents just for this VU
|
|
748
|
+
const agentOpts = Object.assign(DEFAULT_AGENT_OPTIONS, {
|
|
749
|
+
maxSockets: 1,
|
|
750
|
+
maxFreeSockets: 1
|
|
751
|
+
});
|
|
752
|
+
const agentTimeoutMs = deriveAgentTimeoutMs(this.config);
|
|
753
|
+
if (agentTimeoutMs !== undefined) {
|
|
754
|
+
agentOpts.timeout = agentTimeoutMs;
|
|
755
|
+
}
|
|
756
|
+
const agents = createAgents({
|
|
757
|
+
http: process.env.HTTP_PROXY,
|
|
758
|
+
https: process.env.HTTPS_PROXY
|
|
759
|
+
}, agentOpts);
|
|
760
|
+
initialContext._httpAgent = agents.httpAgent;
|
|
761
|
+
initialContext._httpsAgent = agents.httpsAgent;
|
|
762
|
+
}
|
|
763
|
+
return initialContext;
|
|
764
|
+
};
|
|
765
|
+
HttpEngine.prototype.compile = function compile(tasks, _scenarioSpec, ee) {
|
|
766
|
+
const self = this;
|
|
767
|
+
return async function scenario(initialContext, callback) {
|
|
768
|
+
initialContext = self.setInitialContext(initialContext);
|
|
769
|
+
ee.emit('started');
|
|
770
|
+
let context = initialContext;
|
|
771
|
+
for (const task of tasks) {
|
|
772
|
+
try {
|
|
773
|
+
context = await promisify(task)(context);
|
|
774
|
+
}
|
|
775
|
+
catch (taskErr) {
|
|
776
|
+
ee.emit('error', taskErr.code || taskErr.message);
|
|
777
|
+
if (callback) {
|
|
778
|
+
return callback(taskErr, context);
|
|
779
|
+
}
|
|
780
|
+
throw taskErr;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (callback) {
|
|
784
|
+
return callback(null, context);
|
|
785
|
+
}
|
|
786
|
+
return context;
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
function maybePrependBase(uri, config) {
|
|
790
|
+
if (_.startsWith(uri, '/')) {
|
|
791
|
+
return config.target + uri;
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
return uri;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/*
|
|
798
|
+
* Given a dictionary, return a dictionary with all keys lowercased.
|
|
799
|
+
*/
|
|
800
|
+
function lowcaseKeys(h) {
|
|
801
|
+
return _.transform(h, (result, v, k) => {
|
|
802
|
+
result[String(k).toLowerCase()] = v;
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function runOnErrorHooks(functionNames, functions, err, requestParams, context, ee, callback) {
|
|
806
|
+
async.eachSeries(functionNames, function iteratee(functionName, next) {
|
|
807
|
+
const processFunc = functions[functionName];
|
|
808
|
+
processFunc(err, requestParams, context, ee, (asyncErr) => {
|
|
809
|
+
if (asyncErr) {
|
|
810
|
+
return next(asyncErr);
|
|
811
|
+
}
|
|
812
|
+
return next(null);
|
|
813
|
+
});
|
|
814
|
+
}, function done(asyncErr) {
|
|
815
|
+
return callback(asyncErr);
|
|
816
|
+
});
|
|
817
|
+
}
|