@artilleryio/int-core 2.27.0 → 2.28.0-2c19512

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,463 @@
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
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
5
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
6
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
7
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
8
+ });
9
+ }
10
+ return path;
11
+ };
12
+ import { createRequire } from 'node:module';
13
+ import path from 'node:path';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { engine_util as engineUtil } from '@artilleryio/int-commons';
16
+ import createDebug from 'debug';
17
+ import { EventEmitter } from 'eventemitter3';
18
+ import _ from 'lodash';
19
+ import { v4 as uuidv4 } from 'uuid';
20
+ import HttpEngine from "./engine_http.js";
21
+ import SocketIoEngine from "./engine_socketio.js";
22
+ import WSEngine from "./engine_ws.js";
23
+ import createPhaser from "./phases.js";
24
+ import createReader from "./readers.js";
25
+ import { SSMS } from "./ssms.js";
26
+ import wl from "./weighted-pick.js";
27
+ const debug = createDebug('runner');
28
+ const debugPerf = createDebug('perf');
29
+ const require = createRequire(import.meta.url);
30
+ const Engines = {
31
+ http: HttpEngine,
32
+ ws: WSEngine,
33
+ socketio: SocketIoEngine
34
+ };
35
+ const contextFuncs = {
36
+ $randomString,
37
+ $randomNumber
38
+ };
39
+ const runnerFuncs = {
40
+ handleScriptHook,
41
+ prepareScript,
42
+ loadProcessor
43
+ };
44
+ export { runner, contextFuncs, runnerFuncs };
45
+ async function loadEngines(script, ee, warnings = {
46
+ engines: {}
47
+ }) {
48
+ const engineSpecs = Object.assign({}, Engines, script.config.engines);
49
+ const loadedEngines = [];
50
+ for (const engineName of Object.keys(engineSpecs)) {
51
+ const moduleName = `artillery-engine-${engineName}`;
52
+ try {
53
+ let Engine;
54
+ if (typeof Engines[engineName] !== 'undefined') {
55
+ Engine = Engines[engineName];
56
+ }
57
+ else {
58
+ // Resolve with CJS semantics (bare specifiers, directories via
59
+ // package.json "main"), load with import() - handles both CJS
60
+ // and ESM engines, including ESM with top-level await
61
+ const enginePath = require.resolve(moduleName);
62
+ const ns = await import(__rewriteRelativeImportExtension(pathToFileURL(enginePath).href));
63
+ Engine = ns.default ?? ns;
64
+ }
65
+ const engine = new Engine(script, ee, engineUtil);
66
+ engine.__name = engineName;
67
+ loadedEngines.push(engine);
68
+ }
69
+ catch (err) {
70
+ console.log('WARNING: engine %s specified but module %s could not be loaded', engineName, moduleName);
71
+ console.log(err.stack);
72
+ warnings.engines[engineName] = {
73
+ message: 'Could not load',
74
+ error: err
75
+ };
76
+ loadedEngines.push(undefined);
77
+ }
78
+ }
79
+ return { loadedEngines, warnings };
80
+ }
81
+ async function loadProcessor(script, options) {
82
+ const absoluteScriptPath = path.resolve(process.cwd(), options.scriptPath);
83
+ if (script.config.processor) {
84
+ const processorPath = path.resolve(path.dirname(absoluteScriptPath), script.config.processor);
85
+ // Resolve with CJS semantics first (handles extensionless paths and
86
+ // directories); fall back to the path as-is
87
+ let resolvedPath = processorPath;
88
+ try {
89
+ resolvedPath = require.resolve(processorPath);
90
+ }
91
+ catch (_err) { }
92
+ // import() loads both CJS and ESM (including ESM with top-level
93
+ // await). Normalize the result into a plain mutable object: module
94
+ // namespace objects are frozen, and engines/plugins may attach
95
+ // properties to the processor object later (e.g. $rewriteMetricName)
96
+ const ns = await import(__rewriteRelativeImportExtension(pathToFileURL(resolvedPath).href));
97
+ script.config.processor = Object.assign({}, ns.default ?? {}, ns);
98
+ }
99
+ return script;
100
+ }
101
+ function prepareScript(script, payload) {
102
+ const runnableScript = _.cloneDeep(script);
103
+ _.each(runnableScript.config.phases, (phaseSpec) => {
104
+ phaseSpec.mode = phaseSpec.mode || runnableScript.config.mode;
105
+ });
106
+ if (payload) {
107
+ if (_.isArray(payload[0])) {
108
+ runnableScript.config.payload = [
109
+ {
110
+ fields: runnableScript.config.payload.fields,
111
+ reader: createReader(runnableScript.config.payload.order, runnableScript.config.payload),
112
+ data: payload
113
+ }
114
+ ];
115
+ }
116
+ else {
117
+ runnableScript.config.payload = payload;
118
+ _.each(runnableScript.config.payload, (el) => {
119
+ el.reader = createReader(el.order, el);
120
+ });
121
+ }
122
+ }
123
+ else {
124
+ runnableScript.config.payload = null;
125
+ }
126
+ // Flatten flows (can have nested arrays of request specs with YAML references):
127
+ _.each(runnableScript.scenarios, (scenarioSpec) => {
128
+ scenarioSpec.flow = _.flatten(scenarioSpec.flow);
129
+ });
130
+ return runnableScript;
131
+ }
132
+ async function runner(script, payload, options, callback) {
133
+ const opts = _.assign({
134
+ periodicStats: script.config.statsInterval || 30,
135
+ mode: script.config.mode || 'uniform'
136
+ }, options);
137
+ const metrics = new SSMS();
138
+ const warnings = {
139
+ engines: {}
140
+ };
141
+ const runnableScript = prepareScript(script, payload);
142
+ const ee = new EventEmitter();
143
+ //
144
+ // load engines:
145
+ //
146
+ const { loadedEngines: runnerEngines } = await loadEngines(runnableScript, ee, warnings);
147
+ for (const e of runnerEngines) {
148
+ if (e &&
149
+ typeof e.init === 'function' &&
150
+ e.init.constructor.name === 'AsyncFunction') {
151
+ await e.init();
152
+ }
153
+ }
154
+ const promise = new Promise((resolve, _reject) => {
155
+ ee.run = (contextVars) => {
156
+ const runState = {
157
+ pendingScenarios: 0,
158
+ // pendingRequests: 0,
159
+ compiledScenarios: null,
160
+ scenarioEvents: null,
161
+ picker: undefined,
162
+ engines: runnerEngines,
163
+ metrics: metrics
164
+ };
165
+ debug('run() with: %j', runnableScript);
166
+ run(runnableScript, ee, opts, runState, contextVars);
167
+ };
168
+ ee.stop = async (_done) => {
169
+ metrics.stop();
170
+ };
171
+ // FIXME: Warnings should be returned from this function instead along with
172
+ // the event emitter. That will be a breaking change.
173
+ ee.warnings = warnings;
174
+ resolve(ee);
175
+ });
176
+ if (callback && typeof callback === 'function') {
177
+ promise.then(callback.bind(null, null), callback);
178
+ }
179
+ return promise;
180
+ }
181
+ function run(script, ee, options, runState, contextVars) {
182
+ const metrics = runState.metrics;
183
+ const intermediates = [];
184
+ const phaser = createPhaser(script.config.phases);
185
+ let _scenarioContext;
186
+ phaser.on('arrival', (spec) => {
187
+ if (runState.pendingScenarios >= spec.maxVusers) {
188
+ metrics.counter('vusers.skipped', 1);
189
+ }
190
+ else {
191
+ _scenarioContext = runScenario(script, metrics, runState, contextVars, options);
192
+ }
193
+ });
194
+ phaser.on('phaseStarted', (spec) => {
195
+ ee.emit('phaseStarted', spec);
196
+ });
197
+ phaser.on('phaseCompleted', (spec) => {
198
+ ee.emit('phaseCompleted', spec);
199
+ });
200
+ phaser.on('done', () => {
201
+ debug('All phases launched');
202
+ const doneYet = setInterval(function checkIfDone() {
203
+ if (runState.pendingScenarios === 0) {
204
+ clearInterval(doneYet);
205
+ metrics.aggregate(true);
206
+ const totals = SSMS.pack(intermediates);
207
+ ee.emit('done', totals);
208
+ }
209
+ else {
210
+ debug('Pending scenarios: %s', runState.pendingScenarios);
211
+ }
212
+ }, 1000);
213
+ });
214
+ metrics.on('metricData', (_ts, periodData) => {
215
+ const cloned = SSMS.deserializeMetrics(SSMS.serializeMetrics(periodData));
216
+ intermediates.push(periodData);
217
+ ee.emit('stats', cloned);
218
+ });
219
+ phaser.run();
220
+ }
221
+ function runScenario(script, metrics, runState, contextVars, options) {
222
+ const start = process.hrtime();
223
+ //
224
+ // Compile scenarios if needed
225
+ //
226
+ if (!runState.compiledScenarios) {
227
+ _.each(script.scenarios, (scenario) => {
228
+ if (typeof scenario.weight === 'undefined') {
229
+ scenario.weight = 1;
230
+ }
231
+ else {
232
+ debug(`scenario ${scenario.name} weight = ${scenario.weight}`);
233
+ const variableValues = Object.assign(datafileVariables(script), inlineVariables(script), { $processEnvironment: process.env });
234
+ const w = engineUtil.template(scenario.weight, {
235
+ vars: variableValues
236
+ });
237
+ scenario.weight = Number.isNaN(parseInt(w, 10)) ? 0 : parseInt(w, 10);
238
+ debug(`scenario ${scenario.name} weight has been set to ${scenario.weight}`);
239
+ }
240
+ });
241
+ runState.picker = wl(script.scenarios);
242
+ runState.scenarioEvents = new EventEmitter();
243
+ runState.scenarioEvents.on('counter', (name, value) => {
244
+ metrics.counter(name, value);
245
+ });
246
+ // TODO: Deprecate
247
+ runState.scenarioEvents.on('customStat', (stat) => {
248
+ metrics.summary(stat.stat, stat.value);
249
+ });
250
+ runState.scenarioEvents.on('summary', (name, value) => {
251
+ metrics.summary(name, value);
252
+ });
253
+ runState.scenarioEvents.on('histogram', (name, value) => {
254
+ metrics.summary(name, value);
255
+ });
256
+ runState.scenarioEvents.on('rate', (name) => {
257
+ metrics.rate(name);
258
+ });
259
+ runState.scenarioEvents.on('started', () => {
260
+ runState.pendingScenarios++;
261
+ });
262
+ // TODO: Take an object so that it can have code, description etc
263
+ runState.scenarioEvents.on('error', (errCode) => {
264
+ metrics.counter(`errors.${errCode}`, 1);
265
+ });
266
+ runState.compiledScenarios = _.map(script.scenarios, (scenarioSpec, scenarioIndex) => {
267
+ const name = scenarioSpec.engine || script.config.engine || 'http';
268
+ const engine = runState.engines.find((e) => e.__name === name);
269
+ if (typeof engine === 'undefined') {
270
+ const scenarioNameOrIndex = scenarioSpec.name || scenarioIndex;
271
+ throw new Error(`Failed to run scenario "${scenarioNameOrIndex}": unknown engine "${name}". Did you forget to include it in "config.engines.${name}"?`);
272
+ }
273
+ return engine.createScenario(scenarioSpec, runState.scenarioEvents);
274
+ });
275
+ }
276
+ //default to weighted picked scenario
277
+ let i = runState.picker()[0];
278
+ if (options.scenarioName) {
279
+ let foundIndex;
280
+ const foundScenario = script.scenarios.filter((scenario, index) => {
281
+ const hasScenarioByRegex = new RegExp(options.scenarioName).test(scenario.name);
282
+ const hasScenarioByName = scenario.name === options.scenarioName;
283
+ const hasScenario = hasScenarioByName || hasScenarioByRegex;
284
+ if (hasScenario) {
285
+ foundIndex = index;
286
+ }
287
+ return hasScenario;
288
+ });
289
+ if (foundScenario?.length === 0) {
290
+ throw new Error(`Scenario ${options.scenarioName} not found in script. Make sure your chosen scenario matches the one in your script exactly.`);
291
+ }
292
+ else if (foundScenario.length > 1) {
293
+ throw new Error(`Multiple scenarios for ${options.scenarioName} found in script. Make sure you give unique names to your scenarios in your script.`);
294
+ }
295
+ else {
296
+ debug(`Scenario ${options.scenarioName} found in script. running it!`);
297
+ i = foundIndex;
298
+ }
299
+ }
300
+ debug('picking scenario %s (%s) weight = %s', i, script.scenarios[i].name, script.scenarios[i].weight);
301
+ metrics.counter(`vusers.created_by_name.${script.scenarios[i].name || i}`, 1);
302
+ metrics.counter('vusers.created', 1);
303
+ const scenarioStartedAt = process.hrtime();
304
+ const scenarioContext = createContext(script, contextVars, {
305
+ scenario: script.scenarios[i]
306
+ });
307
+ const finish = process.hrtime(start);
308
+ const runScenarioDelta = finish[0] * 1e9 + finish[1];
309
+ debugPerf('runScenarioDelta: %s', Math.round((runScenarioDelta / 1e6) * 100) / 100);
310
+ runState.compiledScenarios[i](scenarioContext, (err, _context) => {
311
+ runState.pendingScenarios--;
312
+ if (err) {
313
+ debug(err);
314
+ metrics.counter('vusers.failed', 1);
315
+ }
316
+ else {
317
+ metrics.counter('vusers.failed', 0);
318
+ metrics.counter('vusers.completed', 1);
319
+ const scenarioFinishedAt = process.hrtime(scenarioStartedAt);
320
+ const delta = scenarioFinishedAt[0] * 1e9 + scenarioFinishedAt[1];
321
+ metrics.summary('vusers.session_length', delta / 1e6);
322
+ }
323
+ });
324
+ return scenarioContext;
325
+ }
326
+ function datafileVariables(script) {
327
+ const result = {};
328
+ if (script.config.payload) {
329
+ _.each(script.config.payload, (el) => {
330
+ if (!el.loadAll) {
331
+ // Load individual fields from the CSV into VU context variables
332
+ // If data = [] (i.e. the CSV file is empty, or only has headers and
333
+ // skipHeaders = true), then row could = undefined
334
+ const row = el.reader(el.data) || [];
335
+ _.each(el.fields, (fieldName, j) => {
336
+ result[fieldName] = row[j];
337
+ });
338
+ }
339
+ else {
340
+ if (typeof el.name !== 'undefined') {
341
+ // Make the entire CSV available
342
+ result[el.name] = el.reader(el.data);
343
+ }
344
+ else {
345
+ console.log('WARNING: loadAll is set to true but no name is provided for the CSV data');
346
+ }
347
+ }
348
+ });
349
+ }
350
+ return result;
351
+ }
352
+ function inlineVariables(script) {
353
+ const result = {};
354
+ if (script.config.variables) {
355
+ _.each(script.config.variables, (v, k) => {
356
+ let val;
357
+ if (_.isArray(v)) {
358
+ val = _.sample(v);
359
+ }
360
+ else {
361
+ val = v;
362
+ }
363
+ result[k] = val;
364
+ });
365
+ }
366
+ return result;
367
+ }
368
+ /**
369
+ * Create initial context for a scenario.
370
+ */
371
+ function createContext(script, contextVars, additionalProperties = {}) {
372
+ //allow for additional properties to be passed in, but not override vars and funcs
373
+ const additionalPropertiesWithoutOverride = _.omit(additionalProperties, [
374
+ 'vars',
375
+ 'funcs'
376
+ ]);
377
+ const INITIAL_CONTEXT = {
378
+ vars: Object.assign({
379
+ target: script.config.target,
380
+ $environment: script._environment,
381
+ $processEnvironment: process.env, // TODO: deprecate
382
+ $env: process.env,
383
+ $testId: global.artillery.testRunId
384
+ }, contextVars || {}),
385
+ funcs: {
386
+ $randomNumber: $randomNumber,
387
+ $randomString: $randomString,
388
+ $template: (input) => engineUtil.template(input, { vars: result.vars })
389
+ },
390
+ ...additionalPropertiesWithoutOverride
391
+ };
392
+ if (script._configPath) {
393
+ INITIAL_CONTEXT.vars.$dirname = path.dirname(script._configPath);
394
+ }
395
+ if (script._scriptPath) {
396
+ INITIAL_CONTEXT.vars.$scenarioFile = script._scriptPath;
397
+ }
398
+ const result = INITIAL_CONTEXT;
399
+ // variables from payloads:
400
+ const variableValues1 = datafileVariables(script);
401
+ Object.assign(result.vars, variableValues1);
402
+ // inline variables:
403
+ const variableValues2 = inlineVariables(script);
404
+ Object.assign(result.vars, variableValues2);
405
+ result._uid = uuidv4();
406
+ result.vars.$uuid = result._uid;
407
+ return result;
408
+ }
409
+ //
410
+ // Generator functions for template strings:
411
+ //
412
+ function $randomNumber(min, max) {
413
+ return _.random(min, max);
414
+ }
415
+ function $randomString(length = 10) {
416
+ let s = '';
417
+ const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
418
+ const alphabetLength = alphabet.length;
419
+ while (s.length < length) {
420
+ s += alphabet.charAt((Math.random() * alphabetLength) | 0);
421
+ }
422
+ return s;
423
+ }
424
+ async function handleScriptHook(hook, script, hookEvents, contextVars = {}) {
425
+ if (!script[hook]) {
426
+ return {};
427
+ }
428
+ const { loadedEngines: engines } = await loadEngines(script, hookEvents);
429
+ for (const e of engines) {
430
+ if (e &&
431
+ typeof e.init === 'function' &&
432
+ e.init.constructor.name === 'AsyncFunction') {
433
+ await e.init();
434
+ }
435
+ }
436
+ const ee = new EventEmitter();
437
+ return new Promise((resolve, reject) => {
438
+ ee.on('request', () => {
439
+ hookEvents.emit(`${hook}TestRequest`);
440
+ });
441
+ ee.on('error', (error) => {
442
+ hookEvents.emit(`${hook}TestError`, error);
443
+ });
444
+ const name = script[hook].engine || 'http';
445
+ const engine = engines.find((e) => e.__name === name);
446
+ if (typeof engine === 'undefined') {
447
+ throw new Error(`Failed to run ${hook} hook: unknown engine "${name}". Did you forget to include it in "config.engines.${name}"?`);
448
+ }
449
+ const hookScenario = engine.createScenario(script[hook], ee);
450
+ const hookContext = createContext(script, contextVars, {
451
+ scenario: script[hook]
452
+ });
453
+ hookScenario(hookContext, (err, context) => {
454
+ if (err) {
455
+ debug(err);
456
+ return reject(err);
457
+ }
458
+ else {
459
+ return resolve(context.vars);
460
+ }
461
+ });
462
+ });
463
+ }