@postman/postman-plugin 0.4.0 → 0.4.1
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/README.md +14 -1
- package/dist/hosts/codex-hooks.js +68 -0
- package/dist/hosts/codex.js +87 -8
- package/dist/json-rpc.js +113 -0
- package/dist/system.js +40 -3
- package/mcp.pi.json +2 -2
- package/package.json +1 -1
- package/skills/performance-testing/SKILL.md +6 -6
- package/skills/performance-testing/references/jmeter-import/README.md +35 -66
- package/skills/performance-testing/references/jmeter-import/import-report.md +2 -2
- package/skills/performance-testing/references/jmeter-import/mapping-table.md +73 -75
- package/skills/performance-testing/references/jmeter-import/postman-perf-capabilities.md +3 -3
- package/skills/performance-testing/references/jmeter-import/script-translation.md +8 -9
- package/skills/performance-testing/references/jmeter-import/scripts/groovy_to_js.py +0 -83
- package/skills/performance-testing/references/jmeter-import/scripts/jmx_to_postman.py +0 -1433
|
@@ -1,1433 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
jmx_to_postman.py — deterministic JMeter (.jmx) -> Postman importer.
|
|
4
|
-
|
|
5
|
-
Pipeline: JMX XML -> intermediate model (IR) -> Collection v2.1 JSON
|
|
6
|
-
-> performance run config
|
|
7
|
-
-> import report
|
|
8
|
-
|
|
9
|
-
Design: everything structural is deterministic and stdlib-only (xml.etree, json,
|
|
10
|
-
argparse). The only judgment calls left to a model are (a) translating
|
|
11
|
-
JSR223/BeanShell scripts to JavaScript and (b) resolving ambiguous extractors
|
|
12
|
-
(XPath/CSS/complex JSONPath). Those are emitted as explicit `manual_tasks` in the
|
|
13
|
-
report so a model can finish them.
|
|
14
|
-
|
|
15
|
-
Usage:
|
|
16
|
-
python3 jmx_to_postman.py INPUT.jmx -o OUTDIR [--name NAME]
|
|
17
|
-
|
|
18
|
-
Outputs in OUTDIR:
|
|
19
|
-
<name>.postman_collection.json Collection v2.1
|
|
20
|
-
<name>.perf.json machine-readable perf config
|
|
21
|
-
<name>.perf.md human-readable perf config + CLI command
|
|
22
|
-
<name>.report.md import report (what/approx/manual)
|
|
23
|
-
"""
|
|
24
|
-
import argparse
|
|
25
|
-
import json
|
|
26
|
-
import os
|
|
27
|
-
import re
|
|
28
|
-
import sys
|
|
29
|
-
import xml.etree.ElementTree as ET
|
|
30
|
-
|
|
31
|
-
try:
|
|
32
|
-
from groovy_to_js import translate as groovy_translate
|
|
33
|
-
except ImportError: # allow running from any cwd
|
|
34
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
-
from groovy_to_js import translate as groovy_translate
|
|
36
|
-
|
|
37
|
-
SCHEMA_V21 = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
38
|
-
|
|
39
|
-
# ResponseAssertion test_type bit flags (org.apache.jmeter.assertions.AssertionGui)
|
|
40
|
-
F_MATCH, F_CONTAINS, F_NOT, F_EQUALS, F_SUBSTRING, F_OR = 1, 2, 4, 8, 16, 32
|
|
41
|
-
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
# XML helpers
|
|
44
|
-
# ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
def _localname(tag):
|
|
47
|
-
return tag.split('}')[-1] if '}' in tag else tag
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def get_prop(el, name, default=''):
|
|
51
|
-
"""Value of a direct <*Prop name="..."> child, as text."""
|
|
52
|
-
for child in el:
|
|
53
|
-
if child.get('name') == name and _localname(child.tag).endswith('Prop'):
|
|
54
|
-
return (child.text or '').strip()
|
|
55
|
-
return default
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def get_bool(el, name, default=False):
|
|
59
|
-
v = get_prop(el, name, '').lower()
|
|
60
|
-
if v == '':
|
|
61
|
-
return default
|
|
62
|
-
return v == 'true'
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def get_element_prop(el, name):
|
|
66
|
-
"""A nested <elementProp name="..."> child element, or None."""
|
|
67
|
-
for child in el:
|
|
68
|
-
if child.get('name') == name and _localname(child.tag) == 'elementProp':
|
|
69
|
-
return child
|
|
70
|
-
return None
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def get_collection_prop(el, name):
|
|
74
|
-
for child in el:
|
|
75
|
-
if child.get('name') == name and _localname(child.tag) == 'collectionProp':
|
|
76
|
-
return child
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def iter_pairs(hashtree):
|
|
81
|
-
"""Yield (element, following_hashTree_or_None) for a <hashTree>.
|
|
82
|
-
|
|
83
|
-
JMeter serialises children as: element, hashTree, element, hashTree, ...
|
|
84
|
-
where each element's children live in the hashTree that immediately follows
|
|
85
|
-
it. This pairing is the backbone of the whole parser.
|
|
86
|
-
"""
|
|
87
|
-
if hashtree is None:
|
|
88
|
-
return
|
|
89
|
-
pending = None
|
|
90
|
-
for child in list(hashtree):
|
|
91
|
-
if _localname(child.tag) == 'hashTree':
|
|
92
|
-
yield (pending, child)
|
|
93
|
-
pending = None
|
|
94
|
-
else:
|
|
95
|
-
if pending is not None:
|
|
96
|
-
yield (pending, None)
|
|
97
|
-
pending = child
|
|
98
|
-
if pending is not None:
|
|
99
|
-
yield (pending, None)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# ---------------------------------------------------------------------------
|
|
103
|
-
# Expression import: JMeter ${...} -> Postman {{...}}
|
|
104
|
-
# ---------------------------------------------------------------------------
|
|
105
|
-
|
|
106
|
-
# A few common JMeter functions with Postman dynamic-variable equivalents.
|
|
107
|
-
_FUNC_MAP = {
|
|
108
|
-
'__UUID': '{{$guid}}',
|
|
109
|
-
'__time': '{{$timestamp}}',
|
|
110
|
-
'__threadNum': '{{$randomInt}}', # approximate; no per-VU index var exists
|
|
111
|
-
'__Random': None, # handled specially (args)
|
|
112
|
-
'__RandomString': None,
|
|
113
|
-
'__counter': None,
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def convert_expr(text, warnings=None):
|
|
118
|
-
"""Translate a JMeter expression string into Postman variable syntax."""
|
|
119
|
-
if not text:
|
|
120
|
-
return text
|
|
121
|
-
out = text
|
|
122
|
-
|
|
123
|
-
# ${__Function(args)} -> best effort
|
|
124
|
-
def repl_func(m):
|
|
125
|
-
fname = m.group(1)
|
|
126
|
-
if fname == '__UUID':
|
|
127
|
-
return '{{$guid}}'
|
|
128
|
-
if fname == '__time':
|
|
129
|
-
return '{{$timestamp}}'
|
|
130
|
-
if fname in ('__Random', '__RandomInt'):
|
|
131
|
-
return '{{$randomInt}}'
|
|
132
|
-
if fname == '__RandomString':
|
|
133
|
-
return '{{$randomAlphaNumeric}}'
|
|
134
|
-
if warnings is not None:
|
|
135
|
-
warnings.append(f'JMeter function {fname}() has no exact Postman equivalent; left as a variable reference for manual review')
|
|
136
|
-
return '{{' + fname.lstrip('_') + '}}'
|
|
137
|
-
|
|
138
|
-
out = re.sub(r'\$\{(__\w+)\s*\([^}]*\)\}', repl_func, out)
|
|
139
|
-
# plain ${var} -> {{var}}
|
|
140
|
-
out = re.sub(r'\$\{([^}]+)\}', r'{{\1}}', out)
|
|
141
|
-
return out
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# ---------------------------------------------------------------------------
|
|
145
|
-
# Intermediate model
|
|
146
|
-
# ---------------------------------------------------------------------------
|
|
147
|
-
# We keep the IR as plain dicts for easy JSON round-tripping. Shapes:
|
|
148
|
-
# plan = {name, variables:{}, thread_groups:[tg], global_mods:{...}, warnings:[], manual_tasks:[]}
|
|
149
|
-
# tg = {name, load:{...}, items:[request|folder], warnings:[]}
|
|
150
|
-
# request = {name, method, url_parts, headers:[], body:{}, auth, extractors:[],
|
|
151
|
-
# assertions:[], pre_scripts:[], post_scripts:[], timers:[], notes:[]}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
SAMPLER_TAGS = {'HTTPSamplerProxy', 'HTTPSampler', 'HTTPSampler2'}
|
|
155
|
-
SCRIPT_SAMPLER_TAGS = {'JSR223Sampler', 'BeanShellSampler'}
|
|
156
|
-
FOLDER_CONTROLLERS = {'TransactionController', 'GenericController', 'SimpleController'}
|
|
157
|
-
FLATTEN_CONTROLLERS = {'LoopController', 'IfController', 'WhileController', 'ForeachController',
|
|
158
|
-
'OnceOnlyController', 'RuntimeController', 'InterleaveControl',
|
|
159
|
-
'RandomController', 'RandomOrderController', 'ThroughputController',
|
|
160
|
-
'CriticalSectionController', 'ModuleController', 'IncludeController'}
|
|
161
|
-
TIMER_TAGS = {'ConstantTimer', 'UniformRandomTimer', 'GaussianRandomTimer', 'PoissonRandomTimer',
|
|
162
|
-
'ConstantThroughputTimer', 'SyncTimer', 'BSFTimer', 'JSR223Timer', 'BeanShellTimer'}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def is_thread_group(tag):
|
|
166
|
-
return 'ThreadGroup' in tag
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
class Ctx:
|
|
170
|
-
"""Inherited scope carried down the tree (config elements, header managers,
|
|
171
|
-
auth, scoped assertions/extractors, pre/post scripts, http defaults)."""
|
|
172
|
-
def __init__(self):
|
|
173
|
-
self.headers = [] # list of (name, value)
|
|
174
|
-
self.auth = None # dict
|
|
175
|
-
self.http_defaults = {} # protocol/domain/port/path/timeouts
|
|
176
|
-
self.assertions = [] # dicts
|
|
177
|
-
self.extractors = [] # dicts
|
|
178
|
-
self.pre_scripts = [] # dicts {lang, script, name}
|
|
179
|
-
self.post_scripts = []
|
|
180
|
-
self.variables = {} # name -> value (User Defined Variables in scope)
|
|
181
|
-
|
|
182
|
-
def child(self):
|
|
183
|
-
c = Ctx()
|
|
184
|
-
c.headers = list(self.headers)
|
|
185
|
-
c.auth = self.auth
|
|
186
|
-
c.http_defaults = dict(self.http_defaults)
|
|
187
|
-
c.assertions = list(self.assertions)
|
|
188
|
-
c.extractors = list(self.extractors)
|
|
189
|
-
c.pre_scripts = list(self.pre_scripts)
|
|
190
|
-
c.post_scripts = list(self.post_scripts)
|
|
191
|
-
c.variables = dict(self.variables)
|
|
192
|
-
return c
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# ---------------------------------------------------------------------------
|
|
196
|
-
# Element parsers (modifiers)
|
|
197
|
-
# ---------------------------------------------------------------------------
|
|
198
|
-
|
|
199
|
-
def parse_header_manager(el):
|
|
200
|
-
out = []
|
|
201
|
-
coll = get_collection_prop(el, 'HeaderManager.headers')
|
|
202
|
-
if coll is None:
|
|
203
|
-
return out
|
|
204
|
-
for ep in coll:
|
|
205
|
-
if _localname(ep.tag) != 'elementProp':
|
|
206
|
-
continue
|
|
207
|
-
name = get_prop(ep, 'Header.name')
|
|
208
|
-
value = get_prop(ep, 'Header.value')
|
|
209
|
-
if name:
|
|
210
|
-
out.append((name, value))
|
|
211
|
-
return out
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def parse_auth_manager(el, warnings):
|
|
215
|
-
coll = get_collection_prop(el, 'AuthManager.auth_list')
|
|
216
|
-
if coll is None:
|
|
217
|
-
return None
|
|
218
|
-
entries = []
|
|
219
|
-
for ep in coll:
|
|
220
|
-
if _localname(ep.tag) != 'elementProp':
|
|
221
|
-
continue
|
|
222
|
-
entries.append({
|
|
223
|
-
'url': get_prop(ep, 'Authorization.url'),
|
|
224
|
-
'username': get_prop(ep, 'Authorization.username'),
|
|
225
|
-
'password': get_prop(ep, 'Authorization.password'),
|
|
226
|
-
'realm': get_prop(ep, 'Authorization.realm'),
|
|
227
|
-
'mechanism': (get_prop(ep, 'Authorization.mechanism') or 'BASIC').upper(),
|
|
228
|
-
})
|
|
229
|
-
if not entries:
|
|
230
|
-
return None
|
|
231
|
-
first = entries[0]
|
|
232
|
-
mech = first['mechanism']
|
|
233
|
-
if len(entries) > 1:
|
|
234
|
-
warnings.append(f'AuthManager has {len(entries)} credential entries; only the first was applied as collection auth. Review per-URL credentials.')
|
|
235
|
-
if mech in ('KERBEROS', 'NTLM'):
|
|
236
|
-
warnings.append(f'Auth mechanism {mech} is not supported by Postman performance runs; emitted as basic auth placeholder.')
|
|
237
|
-
mech = 'BASIC'
|
|
238
|
-
kind = 'digest' if mech == 'DIGEST' else 'basic'
|
|
239
|
-
return {'type': kind, 'username': convert_expr(first['username']), 'password': convert_expr(first['password'])}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def parse_http_defaults(el):
|
|
243
|
-
return {
|
|
244
|
-
'protocol': get_prop(el, 'HTTPSampler.protocol'),
|
|
245
|
-
'domain': get_prop(el, 'HTTPSampler.domain'),
|
|
246
|
-
'port': get_prop(el, 'HTTPSampler.port'),
|
|
247
|
-
'path': get_prop(el, 'HTTPSampler.path'),
|
|
248
|
-
'contentEncoding': get_prop(el, 'HTTPSampler.contentEncoding'),
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def parse_user_variables(el):
|
|
253
|
-
"""Arguments element -> {name: value}."""
|
|
254
|
-
out = {}
|
|
255
|
-
coll = get_collection_prop(el, 'Arguments.arguments')
|
|
256
|
-
if coll is None:
|
|
257
|
-
return out
|
|
258
|
-
for ep in coll:
|
|
259
|
-
if _localname(ep.tag) != 'elementProp':
|
|
260
|
-
continue
|
|
261
|
-
name = get_prop(ep, 'Argument.name') or ep.get('name')
|
|
262
|
-
value = get_prop(ep, 'Argument.value')
|
|
263
|
-
if name:
|
|
264
|
-
out[name] = value
|
|
265
|
-
return out
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
def parse_csv_dataset(el, warnings):
|
|
269
|
-
var_names = get_prop(el, 'variableNames')
|
|
270
|
-
return {
|
|
271
|
-
'filename': get_prop(el, 'filename'),
|
|
272
|
-
'variableNames': [v.strip() for v in var_names.split(',') if v.strip()] if var_names else [],
|
|
273
|
-
'delimiter': get_prop(el, 'delimiter', ','),
|
|
274
|
-
'recycle': get_bool(el, 'recycle', True),
|
|
275
|
-
'ignoreFirstLine': get_bool(el, 'ignoreFirstLine', False),
|
|
276
|
-
'quotedData': get_bool(el, 'quotedData', False),
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
def timer_delay_ms(el, tag):
|
|
281
|
-
"""Representative think-time delay (ms) for a timer element, or None."""
|
|
282
|
-
if tag == 'ConstantTimer':
|
|
283
|
-
return _to_int(get_prop(el, 'ConstantTimer.delay'), 0) or None
|
|
284
|
-
if tag in ('UniformRandomTimer', 'GaussianRandomTimer', 'PoissonRandomTimer'):
|
|
285
|
-
# offset is the fixed part; the random part averages out across requests
|
|
286
|
-
return _to_int(get_prop(el, 'ConstantDelayOffset'), 0) or None
|
|
287
|
-
return None
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def convert_if_condition(cond):
|
|
291
|
-
"""Best-effort JMeter If-Controller condition -> a JS boolean guard.
|
|
292
|
-
${var} becomes pm.variables.get("var"); function calls are left for review."""
|
|
293
|
-
if not cond or not cond.strip():
|
|
294
|
-
return 'true'
|
|
295
|
-
js = re.sub(r'\$\{(__\w+)\s*\([^}]*\)\}', 'true /* review: JMeter function */', cond)
|
|
296
|
-
# quoted variable "${x}" / '${x}' -> String(pm.variables.get("x")) so the
|
|
297
|
-
# surrounding quotes don't produce nested-quote (syntactically broken) JS.
|
|
298
|
-
js = re.sub(r'''(["'])\$\{([^}{]+)\}\1''', r'String(pm.variables.get("\2"))', js)
|
|
299
|
-
js = re.sub(r'\$\{([^}]+)\}', r'pm.variables.get("\1")', js)
|
|
300
|
-
return js.strip()
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _safe_condition(cond):
|
|
304
|
-
"""True if `cond` is confidently valid JS we can inline as a guard. Anything
|
|
305
|
-
with a leftover bare identifier (e.g. a JMeter placeholder like IF1.1) is
|
|
306
|
-
rejected so we never emit a script that fails to parse."""
|
|
307
|
-
t = re.sub(r'/\*.*?\*/', '', cond) # review comments
|
|
308
|
-
t = re.sub(r'"[^"]*"', '', t) # double-quoted strings
|
|
309
|
-
t = re.sub(r"'[^']*'", '', t) # single-quoted strings
|
|
310
|
-
t = re.sub(r'(String\()?pm\.variables\.get\([^)]*\)\)?', '', t) # var reads
|
|
311
|
-
t = re.sub(r'\b(true|false|null|undefined)\b', '', t)
|
|
312
|
-
t = re.sub(r'\d+(\.\d+)?', '', t) # numbers
|
|
313
|
-
t = re.sub(r'[<>=!&|()+\-*/%?:,.\s]', '', t) # allowed operators/punct
|
|
314
|
-
return t == ''
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def parse_script(el, tag):
|
|
318
|
-
"""JSR223 / BeanShell element -> {lang, script, raw_name}."""
|
|
319
|
-
if tag.startswith('BeanShell'):
|
|
320
|
-
src = get_prop(el, 'BeanShellSampler.query') or get_prop(el, 'query') or get_prop(el, 'script')
|
|
321
|
-
lang = 'beanshell'
|
|
322
|
-
else:
|
|
323
|
-
src = get_prop(el, 'script')
|
|
324
|
-
lang = get_prop(el, 'scriptLanguage') or 'groovy'
|
|
325
|
-
return {'lang': lang, 'script': src, 'name': el.get('testname', '')}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
# --- extractors -------------------------------------------------------------
|
|
329
|
-
|
|
330
|
-
def parse_extractor(el, tag):
|
|
331
|
-
"""Return a normalised extractor dict or None if unrecognised."""
|
|
332
|
-
if tag == 'RegexExtractor':
|
|
333
|
-
return {'kind': 'regex',
|
|
334
|
-
'ref': get_prop(el, 'RegexExtractor.refname'),
|
|
335
|
-
'regex': get_prop(el, 'RegexExtractor.regex'),
|
|
336
|
-
'template': get_prop(el, 'RegexExtractor.template', '$1$'),
|
|
337
|
-
'match_no': get_prop(el, 'RegexExtractor.match_number', '1'),
|
|
338
|
-
'default': get_prop(el, 'RegexExtractor.default'),
|
|
339
|
-
'from_headers': get_bool(el, 'RegexExtractor.useHeaders')}
|
|
340
|
-
if tag == 'BoundaryExtractor':
|
|
341
|
-
return {'kind': 'boundary',
|
|
342
|
-
'ref': get_prop(el, 'BoundaryExtractor.refname'),
|
|
343
|
-
'lboundary': get_prop(el, 'BoundaryExtractor.lboundary'),
|
|
344
|
-
'rboundary': get_prop(el, 'BoundaryExtractor.rboundary'),
|
|
345
|
-
'match_no': get_prop(el, 'BoundaryExtractor.match_number', '1'),
|
|
346
|
-
'default': get_prop(el, 'BoundaryExtractor.default'),
|
|
347
|
-
'from_headers': get_bool(el, 'BoundaryExtractor.useHeaders')}
|
|
348
|
-
if tag == 'JSONPostProcessor':
|
|
349
|
-
refs = get_prop(el, 'JSONPostProcessor.referenceNames')
|
|
350
|
-
exprs = get_prop(el, 'JSONPostProcessor.jsonPathExprs')
|
|
351
|
-
defs = get_prop(el, 'JSONPostProcessor.defaultValues')
|
|
352
|
-
return {'kind': 'jsonpath_multi',
|
|
353
|
-
'refs': [r.strip() for r in refs.split(';')] if refs else [],
|
|
354
|
-
'exprs': [e.strip() for e in exprs.split(';')] if exprs else [],
|
|
355
|
-
'defaults': [d.strip() for d in defs.split(';')] if defs else []}
|
|
356
|
-
if tag.endswith('JSONPathExtractor'): # atlantbh plugin
|
|
357
|
-
return {'kind': 'jsonpath_single',
|
|
358
|
-
'ref': get_prop(el, 'VAR'),
|
|
359
|
-
'expr': get_prop(el, 'JSONPATH'),
|
|
360
|
-
'default': get_prop(el, 'DEFAULT')}
|
|
361
|
-
if tag in ('XPathExtractor', 'XPath2Extractor'):
|
|
362
|
-
return {'kind': 'xpath',
|
|
363
|
-
'ref': get_prop(el, 'XPathExtractor.refname'),
|
|
364
|
-
'query': get_prop(el, 'XPathExtractor.xpathQuery') or get_prop(el, 'XPathExtractor.xpathQuery'),
|
|
365
|
-
'default': get_prop(el, 'XPathExtractor.default')}
|
|
366
|
-
if tag == 'HtmlExtractor':
|
|
367
|
-
return {'kind': 'css',
|
|
368
|
-
'ref': get_prop(el, 'HtmlExtractor.refname'),
|
|
369
|
-
'expr': get_prop(el, 'HtmlExtractor.expr'),
|
|
370
|
-
'attr': get_prop(el, 'HtmlExtractor.attribute'),
|
|
371
|
-
'default': get_prop(el, 'HtmlExtractor.default')}
|
|
372
|
-
return None
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
# --- assertions -------------------------------------------------------------
|
|
376
|
-
|
|
377
|
-
def parse_assertion(el, tag):
|
|
378
|
-
if tag == 'ResponseAssertion':
|
|
379
|
-
strings = []
|
|
380
|
-
coll = get_collection_prop(el, 'Asserion.test_strings') # yes, JMeter typo
|
|
381
|
-
if coll is None:
|
|
382
|
-
coll = get_collection_prop(el, 'Assertion.test_strings')
|
|
383
|
-
if coll is not None:
|
|
384
|
-
for sp in coll:
|
|
385
|
-
strings.append((sp.text or '').strip())
|
|
386
|
-
try:
|
|
387
|
-
ttype = int(get_prop(el, 'Assertion.test_type', '0') or '0')
|
|
388
|
-
except ValueError:
|
|
389
|
-
ttype = 0
|
|
390
|
-
return {'kind': 'response',
|
|
391
|
-
'field': get_prop(el, 'Assertion.test_field', 'Assertion.response_data'),
|
|
392
|
-
'test_type': ttype,
|
|
393
|
-
'strings': [s for s in strings if s],
|
|
394
|
-
'assume_success': get_bool(el, 'Assertion.assume_success')}
|
|
395
|
-
if tag == 'DurationAssertion':
|
|
396
|
-
return {'kind': 'duration', 'ms': get_prop(el, 'DurationAssertion.duration')}
|
|
397
|
-
if tag == 'SizeAssertion':
|
|
398
|
-
return {'kind': 'size', 'bytes': get_prop(el, 'SizeAssertion.size'),
|
|
399
|
-
'op': get_prop(el, 'SizeAssertion.operator', '1')}
|
|
400
|
-
if tag in ('JSONPathAssertion', 'JMESPathAssertion'):
|
|
401
|
-
return {'kind': 'jsonpath',
|
|
402
|
-
'path': get_prop(el, 'JSON_PATH'),
|
|
403
|
-
'expected': get_prop(el, 'EXPECTED_VALUE'),
|
|
404
|
-
'validate': get_bool(el, 'JSONVALIDATION')}
|
|
405
|
-
return None
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
# ---------------------------------------------------------------------------
|
|
409
|
-
# HTTP sampler -> request IR
|
|
410
|
-
# ---------------------------------------------------------------------------
|
|
411
|
-
|
|
412
|
-
def parse_sampler(el, ctx, warnings):
|
|
413
|
-
name = el.get('testname', 'Request')
|
|
414
|
-
raw_method = get_prop(el, 'HTTPSampler.method') or 'GET'
|
|
415
|
-
# A variable-driven method (${method}) must become {{method}}, not be
|
|
416
|
-
# uppercased into a broken variable name.
|
|
417
|
-
method = convert_expr(raw_method, warnings) if '${' in raw_method else raw_method.upper()
|
|
418
|
-
d = ctx.http_defaults
|
|
419
|
-
path = get_prop(el, 'HTTPSampler.path') or d.get('path') or '/'
|
|
420
|
-
absolute = path.startswith('http://') or path.startswith('https://')
|
|
421
|
-
protocol = get_prop(el, 'HTTPSampler.protocol') or d.get('protocol') or ''
|
|
422
|
-
if not protocol and not absolute:
|
|
423
|
-
protocol = 'http'
|
|
424
|
-
warnings.append('Some requests had no protocol set in JMeter; defaulted to http (JMeter default). Switch to https if the target requires TLS.')
|
|
425
|
-
domain = get_prop(el, 'HTTPSampler.domain') or d.get('domain') or ''
|
|
426
|
-
port = get_prop(el, 'HTTPSampler.port') or d.get('port') or ''
|
|
427
|
-
raw_body = get_bool(el, 'HTTPSampler.postBodyRaw')
|
|
428
|
-
|
|
429
|
-
# arguments
|
|
430
|
-
args = []
|
|
431
|
-
argcoll = None
|
|
432
|
-
ep = get_element_prop(el, 'HTTPsampler.Arguments')
|
|
433
|
-
if ep is not None:
|
|
434
|
-
argcoll = get_collection_prop(ep, 'Arguments.arguments')
|
|
435
|
-
if argcoll is not None:
|
|
436
|
-
for a in argcoll:
|
|
437
|
-
if _localname(a.tag) != 'elementProp':
|
|
438
|
-
continue
|
|
439
|
-
args.append({'name': get_prop(a, 'Argument.name'),
|
|
440
|
-
'value': get_prop(a, 'Argument.value')})
|
|
441
|
-
|
|
442
|
-
body = None
|
|
443
|
-
query = []
|
|
444
|
-
if raw_body:
|
|
445
|
-
raw = args[0]['value'] if args else ''
|
|
446
|
-
body = {'mode': 'raw', 'raw': convert_expr(raw)}
|
|
447
|
-
elif method in ('POST', 'PUT', 'PATCH') and args:
|
|
448
|
-
if get_bool(el, 'HTTPSampler.DO_MULTIPART_POST'):
|
|
449
|
-
body = {'mode': 'formdata',
|
|
450
|
-
'formdata': [{'key': convert_expr(a['name']), 'value': convert_expr(a['value']), 'type': 'text'} for a in args if a['name']]}
|
|
451
|
-
else:
|
|
452
|
-
body = {'mode': 'urlencoded',
|
|
453
|
-
'urlencoded': [{'key': convert_expr(a['name']), 'value': convert_expr(a['value']), 'type': 'text'} for a in args if a['name']]}
|
|
454
|
-
elif method in ('HEAD', 'TRACE') and args:
|
|
455
|
-
# JMeter does not append sampler parameters as a query string for HEAD/TRACE.
|
|
456
|
-
warnings.append(f'Request "{name}" ({method}): JMeter ignores separate parameters for {method}; they were dropped from the URL.')
|
|
457
|
-
else:
|
|
458
|
-
query = [{'key': convert_expr(a['name']), 'value': convert_expr(a['value'])} for a in args if a['name']]
|
|
459
|
-
|
|
460
|
-
# headers: JMeter merges every in-scope HeaderManager. Keep the last value per
|
|
461
|
-
# name (nearer scope / later entry wins) and warn when a duplicate collapsed,
|
|
462
|
-
# rather than silently dropping or emitting two same-named headers.
|
|
463
|
-
hdr = {}
|
|
464
|
-
dup = False
|
|
465
|
-
for n, v in ctx.headers:
|
|
466
|
-
k = n.lower()
|
|
467
|
-
if k in hdr:
|
|
468
|
-
dup = True
|
|
469
|
-
hdr[k] = (n, v)
|
|
470
|
-
headers = [{'key': convert_expr(o), 'value': convert_expr(v)} for (o, v) in hdr.values()]
|
|
471
|
-
if dup:
|
|
472
|
-
warnings.append(f'Request "{name}" had duplicate header names; kept the last value per name (JMeter merges in-scope managers). Verify the intended values.')
|
|
473
|
-
|
|
474
|
-
req = {
|
|
475
|
-
'name': name,
|
|
476
|
-
'method': method,
|
|
477
|
-
'url': {
|
|
478
|
-
'protocol': protocol.lower() if protocol else '',
|
|
479
|
-
'domain': convert_expr(domain),
|
|
480
|
-
'port': port,
|
|
481
|
-
'path': convert_expr(path),
|
|
482
|
-
'query': query,
|
|
483
|
-
},
|
|
484
|
-
'headers': headers,
|
|
485
|
-
'body': body,
|
|
486
|
-
'auth': ctx.auth,
|
|
487
|
-
'extractors': list(ctx.extractors),
|
|
488
|
-
'assertions': list(ctx.assertions),
|
|
489
|
-
'pre_scripts': list(ctx.pre_scripts),
|
|
490
|
-
'post_scripts': list(ctx.post_scripts),
|
|
491
|
-
'timers': [],
|
|
492
|
-
'guards': [],
|
|
493
|
-
'notes': [],
|
|
494
|
-
}
|
|
495
|
-
return req
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
# ---------------------------------------------------------------------------
|
|
499
|
-
# Recursive tree walk
|
|
500
|
-
# ---------------------------------------------------------------------------
|
|
501
|
-
|
|
502
|
-
def collect_modifiers(pairs, ctx, plan):
|
|
503
|
-
"""First pass at a level: fold config/assertion/extractor/script elements
|
|
504
|
-
into a child context that applies to every sampler at this level and below."""
|
|
505
|
-
c = ctx.child()
|
|
506
|
-
for el, _sub in pairs:
|
|
507
|
-
if el is None:
|
|
508
|
-
continue
|
|
509
|
-
tag = _localname(el.tag)
|
|
510
|
-
if el.get('enabled', 'true') == 'false':
|
|
511
|
-
continue
|
|
512
|
-
if tag == 'HeaderManager':
|
|
513
|
-
for n, v in parse_header_manager(el):
|
|
514
|
-
c.headers.append((n, v))
|
|
515
|
-
elif tag == 'AuthManager':
|
|
516
|
-
a = parse_auth_manager(el, plan['warnings'])
|
|
517
|
-
if a:
|
|
518
|
-
c.auth = a
|
|
519
|
-
elif tag == 'ConfigTestElement' and el.get('guiclass') == 'HttpDefaultsGui':
|
|
520
|
-
c.http_defaults.update({k: v for k, v in parse_http_defaults(el).items() if v})
|
|
521
|
-
elif tag == 'Arguments':
|
|
522
|
-
c.variables.update(parse_user_variables(el))
|
|
523
|
-
plan['variables'].update({k: convert_expr(v) for k, v in parse_user_variables(el).items()})
|
|
524
|
-
elif tag == 'CSVDataSet':
|
|
525
|
-
plan['data_sets'].append(parse_csv_dataset(el, plan['warnings']))
|
|
526
|
-
elif tag in ('JSR223PreProcessor', 'BeanShellPreProcessor'):
|
|
527
|
-
c.pre_scripts.append(parse_script(el, tag))
|
|
528
|
-
elif tag in ('JSR223PostProcessor', 'BeanShellPostProcessor'):
|
|
529
|
-
c.post_scripts.append(parse_script(el, tag))
|
|
530
|
-
elif tag == 'CookieManager':
|
|
531
|
-
plan['warnings'].append('HTTP Cookie Manager found: Postman manages cookies automatically per virtual user; preset cookies were not transferred.')
|
|
532
|
-
elif tag in ('CacheManager', 'DNSCacheManager'):
|
|
533
|
-
plan['warnings'].append(f'{tag} is a no-op in Postman and was skipped.')
|
|
534
|
-
else:
|
|
535
|
-
ext = parse_extractor(el, tag)
|
|
536
|
-
if ext:
|
|
537
|
-
c.extractors.append(ext)
|
|
538
|
-
continue
|
|
539
|
-
asrt = parse_assertion(el, tag)
|
|
540
|
-
if asrt:
|
|
541
|
-
c.assertions.append(asrt)
|
|
542
|
-
return c
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def _attach_skip_guard(entry, cond_js):
|
|
546
|
-
"""Recursively add an If-Controller skip guard to every request in an entry."""
|
|
547
|
-
if entry['type'] == 'request':
|
|
548
|
-
entry['req'].setdefault('guards', []).append(cond_js)
|
|
549
|
-
elif entry['type'] == 'folder':
|
|
550
|
-
for child in entry['items']:
|
|
551
|
-
_attach_skip_guard(child, cond_js)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
def walk(hashtree, ctx, plan, out_items):
|
|
555
|
-
"""Walk a hashTree, appending request/folder IR dicts into out_items."""
|
|
556
|
-
pairs = list(iter_pairs(hashtree))
|
|
557
|
-
c = collect_modifiers(pairs, ctx, plan)
|
|
558
|
-
|
|
559
|
-
for el, sub in pairs:
|
|
560
|
-
if el is None:
|
|
561
|
-
continue
|
|
562
|
-
tag = _localname(el.tag)
|
|
563
|
-
if el.get('enabled', 'true') == 'false':
|
|
564
|
-
continue
|
|
565
|
-
|
|
566
|
-
if tag in SAMPLER_TAGS:
|
|
567
|
-
# Collect the sampler's own hashTree first so request-scoped header
|
|
568
|
-
# managers, auth, HTTP defaults, extractors and assertions all apply.
|
|
569
|
-
sc = collect_modifiers(list(iter_pairs(sub)), c, plan) if sub is not None else c
|
|
570
|
-
req = parse_sampler(el, sc, plan['warnings'])
|
|
571
|
-
req['extractors'] = sc.extractors
|
|
572
|
-
req['assertions'] = sc.assertions
|
|
573
|
-
req['post_scripts'] = sc.post_scripts
|
|
574
|
-
req['pre_scripts'] = sc.pre_scripts
|
|
575
|
-
if sub is not None:
|
|
576
|
-
# timers under the sampler = think time, which Postman performance
|
|
577
|
-
# runs have no equivalent for. Report it; never fake it.
|
|
578
|
-
for tel, _ in iter_pairs(sub):
|
|
579
|
-
if tel is not None and _localname(tel.tag) in TIMER_TAGS:
|
|
580
|
-
ttag = _localname(tel.tag)
|
|
581
|
-
req['timers'].append(ttag)
|
|
582
|
-
d = timer_delay_ms(tel, ttag)
|
|
583
|
-
suffix = f' ({d}ms)' if d else ''
|
|
584
|
-
plan['warnings'].append(f'Timer "{tel.get("testname", ttag)}"{suffix} on request "{req["name"]}" is think time; Postman performance runs have no think time, so it was dropped.')
|
|
585
|
-
out_items.append({'type': 'request', 'req': req})
|
|
586
|
-
|
|
587
|
-
elif tag in SCRIPT_SAMPLER_TAGS:
|
|
588
|
-
sc = parse_script(el, tag)
|
|
589
|
-
plan['script_samplers'].append(sc)
|
|
590
|
-
plan['warnings'].append(f'Script sampler "{sc["name"]}" ({sc["lang"]}) makes no HTTP call; emitted as a script-only request stub for manual review.')
|
|
591
|
-
out_items.append({'type': 'script_request', 'script': sc})
|
|
592
|
-
|
|
593
|
-
elif tag in FOLDER_CONTROLLERS:
|
|
594
|
-
folder = {'type': 'folder', 'name': el.get('testname', 'Group'), 'items': []}
|
|
595
|
-
walk(sub, c, plan, folder['items'])
|
|
596
|
-
if folder['items']:
|
|
597
|
-
out_items.append(folder)
|
|
598
|
-
|
|
599
|
-
elif tag == 'IfController':
|
|
600
|
-
# Import the condition into a per-request skip guard rather than
|
|
601
|
-
# running the enclosed requests unconditionally.
|
|
602
|
-
cond_js = convert_if_condition(get_prop(el, 'IfController.condition'))
|
|
603
|
-
inner = []
|
|
604
|
-
walk(sub, c, plan, inner)
|
|
605
|
-
for entry in inner:
|
|
606
|
-
_attach_skip_guard(entry, cond_js)
|
|
607
|
-
out_items.extend(inner)
|
|
608
|
-
plan['warnings'].append(f'If Controller "{el.get("testname","")}" -> a pre-request skip guard ({cond_js}) was added to each enclosed request (pm.execution.skipRequest). Review the condition.')
|
|
609
|
-
|
|
610
|
-
elif tag in FLATTEN_CONTROLLERS:
|
|
611
|
-
# flatten children into the current level
|
|
612
|
-
walk(sub, c, plan, out_items)
|
|
613
|
-
|
|
614
|
-
elif tag in TIMER_TAGS:
|
|
615
|
-
d = timer_delay_ms(el, tag)
|
|
616
|
-
suffix = f', {d}ms' if d else ''
|
|
617
|
-
plan['warnings'].append(f'Timer "{el.get("testname", tag)}" ({tag}{suffix}) at group/plan scope is think time / pacing; Postman performance runs have no think time, so it was dropped.')
|
|
618
|
-
|
|
619
|
-
elif tag == 'TestAction':
|
|
620
|
-
plan['warnings'].append(f'Test Action "{el.get("testname","")}" (pause/stop) has no Postman performance equivalent and was dropped.')
|
|
621
|
-
|
|
622
|
-
elif tag.endswith('Sampler'):
|
|
623
|
-
friendly = tag.split('.')[-1]
|
|
624
|
-
plan['warnings'].append(f'Sampler "{el.get("testname", friendly)}" is a {friendly} (non-HTTP). Postman performance runs execute HTTP requests only; it was skipped.')
|
|
625
|
-
|
|
626
|
-
elif is_thread_group(tag):
|
|
627
|
-
# nested thread groups shouldn't appear here; ignore
|
|
628
|
-
pass
|
|
629
|
-
|
|
630
|
-
else:
|
|
631
|
-
# Unknown container (RecordingController, TestFragment, custom controller...).
|
|
632
|
-
# Recurse so nested HTTP samplers / timers are not silently lost.
|
|
633
|
-
if sub is not None and len(list(sub)) > 0 and tag not in ('WorkBench', 'ResultCollector'):
|
|
634
|
-
walk(sub, c, plan, out_items)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
# ---------------------------------------------------------------------------
|
|
638
|
-
# Thread group -> load config
|
|
639
|
-
# ---------------------------------------------------------------------------
|
|
640
|
-
|
|
641
|
-
def parse_thread_group(el):
|
|
642
|
-
"""Normalise any thread-group variant into a common load dict."""
|
|
643
|
-
tag = _localname(el.tag)
|
|
644
|
-
name = el.get('testname', 'Thread Group')
|
|
645
|
-
|
|
646
|
-
if 'ConcurrencyThreadGroup' in tag or 'ArrivalsThreadGroup' in tag:
|
|
647
|
-
unit = get_prop(el, 'Unit', 'S')
|
|
648
|
-
ramp = _to_int(get_prop(el, 'RampUp'))
|
|
649
|
-
hold = _to_int(get_prop(el, 'Hold'))
|
|
650
|
-
target = _to_int(get_prop(el, 'TargetLevel'), 1)
|
|
651
|
-
mult = 60 if unit.upper().startswith('M') else 1
|
|
652
|
-
dur = (ramp + hold) * mult
|
|
653
|
-
return {'name': name, 'kind': 'concurrency', 'vus': target,
|
|
654
|
-
'ramp_s': ramp * mult, 'duration_s': dur, 'loops': None,
|
|
655
|
-
'scheduler': True, 'raw': {'TargetLevel': target, 'RampUp': ramp, 'Hold': hold, 'Steps': get_prop(el, 'Steps'), 'Unit': unit}}
|
|
656
|
-
|
|
657
|
-
if 'SteppingThreadGroup' in tag:
|
|
658
|
-
threads = _to_int(get_prop(el, 'ThreadGroup.num_threads'), 1)
|
|
659
|
-
return {'name': name, 'kind': 'stepping', 'vus': threads,
|
|
660
|
-
'ramp_s': _to_int(get_prop(el, 'rampUp')), 'duration_s': _to_int(get_prop(el, 'flighttime')),
|
|
661
|
-
'loops': None, 'scheduler': True,
|
|
662
|
-
'raw': {'num_threads': threads, 'Start users count': get_prop(el, 'Start users count'),
|
|
663
|
-
'Start users period': get_prop(el, 'Start users period'), 'flighttime': get_prop(el, 'flighttime')}}
|
|
664
|
-
|
|
665
|
-
if 'UltimateThreadGroup' in tag:
|
|
666
|
-
# collectionProp of rows [start, startup, hold, shutdown]; take max concurrency & total time
|
|
667
|
-
maxv, total = 1, 0
|
|
668
|
-
coll = get_collection_prop(el, 'ultimatethreadgroupdata')
|
|
669
|
-
if coll is not None:
|
|
670
|
-
for row in coll:
|
|
671
|
-
vals = [(_localname(c.tag), (c.text or '').strip()) for c in row]
|
|
672
|
-
nums = [_to_int(v) for _, v in vals]
|
|
673
|
-
if nums:
|
|
674
|
-
maxv = max(maxv, nums[0] if nums else 1)
|
|
675
|
-
total = max(total, sum(nums[1:]) if len(nums) > 1 else 0)
|
|
676
|
-
return {'name': name, 'kind': 'ultimate', 'vus': maxv, 'ramp_s': 0,
|
|
677
|
-
'duration_s': total, 'loops': None, 'scheduler': True, 'raw': {'rows': 'see jmx'}}
|
|
678
|
-
|
|
679
|
-
# standard ThreadGroup
|
|
680
|
-
threads = _to_int(get_prop(el, 'ThreadGroup.num_threads'), 1)
|
|
681
|
-
ramp = _to_int(get_prop(el, 'ThreadGroup.ramp_time'))
|
|
682
|
-
scheduler = get_bool(el, 'ThreadGroup.scheduler')
|
|
683
|
-
duration = _to_int(get_prop(el, 'ThreadGroup.duration')) if scheduler else 0
|
|
684
|
-
loops = None
|
|
685
|
-
mc = get_element_prop(el, 'ThreadGroup.main_controller')
|
|
686
|
-
if mc is not None:
|
|
687
|
-
if get_bool(mc, 'LoopController.continue_forever'):
|
|
688
|
-
loops = -1
|
|
689
|
-
else:
|
|
690
|
-
loops = _to_int(get_prop(mc, 'LoopController.loops'), 1)
|
|
691
|
-
return {'name': name, 'kind': 'standard', 'vus': threads, 'ramp_s': ramp,
|
|
692
|
-
'duration_s': duration, 'loops': loops, 'scheduler': scheduler, 'raw': {}}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def _to_int(s, default=0):
|
|
696
|
-
try:
|
|
697
|
-
return int(float(str(s).strip()))
|
|
698
|
-
except (ValueError, AttributeError):
|
|
699
|
-
return default
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
# ---------------------------------------------------------------------------
|
|
703
|
-
# Top-level parse
|
|
704
|
-
# ---------------------------------------------------------------------------
|
|
705
|
-
|
|
706
|
-
_MAX_JMX_BYTES = 25 * 1024 * 1024
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
class _NoDTDBuilder(ET.TreeBuilder):
|
|
710
|
-
"""Refuse any DOCTYPE so a crafted JMX can't pull an XXE / entity bomb."""
|
|
711
|
-
def doctype(self, name, pubid, system):
|
|
712
|
-
raise ValueError('DTD/DOCTYPE is not allowed in a JMX file (XXE safety)')
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
def _secure_parse(path):
|
|
716
|
-
with open(path, 'rb') as f:
|
|
717
|
-
data = f.read(_MAX_JMX_BYTES + 1)
|
|
718
|
-
if len(data) > _MAX_JMX_BYTES:
|
|
719
|
-
raise ValueError('JMX file exceeds the 25 MiB safety cap')
|
|
720
|
-
if re.search(br'<!\s*(?:DOCTYPE|ENTITY)\b', data, re.I):
|
|
721
|
-
raise ValueError('JMX contains a DTD/ENTITY declaration; refused for XXE safety')
|
|
722
|
-
return ET.fromstring(data, parser=ET.XMLParser(target=_NoDTDBuilder()))
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def parse_jmx(path):
|
|
726
|
-
root = _secure_parse(path)
|
|
727
|
-
top = None
|
|
728
|
-
for child in root:
|
|
729
|
-
if _localname(child.tag) == 'hashTree':
|
|
730
|
-
top = child
|
|
731
|
-
break
|
|
732
|
-
if top is None:
|
|
733
|
-
raise ValueError('No <hashTree> under <jmeterTestPlan>; not a valid JMX file.')
|
|
734
|
-
|
|
735
|
-
plan = {'name': None, 'variables': {}, 'thread_groups': [], 'data_sets': [],
|
|
736
|
-
'warnings': [], 'script_samplers': [], 'global_mods': None,
|
|
737
|
-
'request_delay_ms': None}
|
|
738
|
-
|
|
739
|
-
# Locate the TestPlan + its hashTree
|
|
740
|
-
testplan_el, testplan_ht = None, None
|
|
741
|
-
for el, sub in iter_pairs(top):
|
|
742
|
-
if el is not None and _localname(el.tag) == 'TestPlan':
|
|
743
|
-
testplan_el, testplan_ht = el, sub
|
|
744
|
-
break
|
|
745
|
-
if testplan_el is None:
|
|
746
|
-
# some plans put thread groups directly under top (rare)
|
|
747
|
-
testplan_ht = top
|
|
748
|
-
plan['name'] = 'Imported Test Plan'
|
|
749
|
-
else:
|
|
750
|
-
plan['name'] = testplan_el.get('testname', 'Imported Test Plan')
|
|
751
|
-
udv = get_element_prop(testplan_el, 'TestPlan.user_defined_variables')
|
|
752
|
-
if udv is not None:
|
|
753
|
-
plan['variables'].update({k: convert_expr(v) for k, v in parse_user_variables(udv).items()})
|
|
754
|
-
|
|
755
|
-
# Plan-level modifiers (apply to all thread groups)
|
|
756
|
-
plan_pairs = list(iter_pairs(testplan_ht))
|
|
757
|
-
base_ctx = collect_modifiers(plan_pairs, Ctx(), plan)
|
|
758
|
-
|
|
759
|
-
# Thread groups
|
|
760
|
-
for el, sub in plan_pairs:
|
|
761
|
-
if el is None:
|
|
762
|
-
continue
|
|
763
|
-
tag = _localname(el.tag)
|
|
764
|
-
if is_thread_group(tag):
|
|
765
|
-
if el.get('enabled', 'true') == 'false':
|
|
766
|
-
plan['warnings'].append(f'Thread group "{el.get("testname","")}" is disabled; skipped.')
|
|
767
|
-
continue
|
|
768
|
-
load = parse_thread_group(el)
|
|
769
|
-
for prop_name in ('ThreadGroup.num_threads', 'ThreadGroup.ramp_time', 'ThreadGroup.duration', 'TargetLevel', 'RampUp', 'Hold'):
|
|
770
|
-
raw = get_prop(el, prop_name)
|
|
771
|
-
if raw and '${' in raw:
|
|
772
|
-
plan['warnings'].append(f'Thread group "{load["name"]}" has a variable-driven load value {prop_name}="{raw}" that could not be resolved to a number; set VUs/duration manually.')
|
|
773
|
-
# setUp / tearDown thread groups run once per lifecycle, not looped by
|
|
774
|
-
# every VU. Tag them so they are severed into their own collections.
|
|
775
|
-
if 'SetupThreadGroup' in tag:
|
|
776
|
-
load['role'] = 'setup'
|
|
777
|
-
elif 'PostThreadGroup' in tag:
|
|
778
|
-
load['role'] = 'teardown'
|
|
779
|
-
else:
|
|
780
|
-
load['role'] = 'main'
|
|
781
|
-
items = []
|
|
782
|
-
walk(sub, base_ctx, plan, items)
|
|
783
|
-
plan['thread_groups'].append({'load': load, 'items': items})
|
|
784
|
-
|
|
785
|
-
if not plan['thread_groups']:
|
|
786
|
-
# Capture samplers/scripts that live outside any thread group (root-level
|
|
787
|
-
# JSR223/BeanShell samplers, plans with no thread group wrapper).
|
|
788
|
-
items = []
|
|
789
|
-
walk(testplan_ht, base_ctx, plan, items)
|
|
790
|
-
if items:
|
|
791
|
-
default_load = {'name': plan['name'], 'kind': 'standard', 'vus': 1, 'ramp_s': 0,
|
|
792
|
-
'duration_s': 0, 'loops': 1, 'scheduler': False, 'raw': {}}
|
|
793
|
-
plan['thread_groups'].append({'load': default_load, 'items': items})
|
|
794
|
-
plan['warnings'].append('No thread group wraps these requests; assumed a default 1-VU load. Set your own VU count and duration.')
|
|
795
|
-
|
|
796
|
-
if not plan['thread_groups']:
|
|
797
|
-
plan['warnings'].append('No enabled thread groups (or importable HTTP samplers) found; the collection is empty.')
|
|
798
|
-
|
|
799
|
-
# dedupe warnings (preserve order) and data sets (by filename)
|
|
800
|
-
seen, dd = set(), []
|
|
801
|
-
for w in plan['warnings']:
|
|
802
|
-
if w not in seen:
|
|
803
|
-
seen.add(w)
|
|
804
|
-
dd.append(w)
|
|
805
|
-
plan['warnings'] = dd
|
|
806
|
-
seenf, dsd = set(), []
|
|
807
|
-
for ds in plan['data_sets']:
|
|
808
|
-
if ds['filename'] not in seenf:
|
|
809
|
-
seenf.add(ds['filename'])
|
|
810
|
-
dsd.append(ds)
|
|
811
|
-
plan['data_sets'] = dsd
|
|
812
|
-
return plan
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
# ===========================================================================
|
|
816
|
-
# BUILDERS
|
|
817
|
-
# ===========================================================================
|
|
818
|
-
|
|
819
|
-
def _escape_js(s):
|
|
820
|
-
return (s or '').replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '')
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
def _wrap_iife(lines):
|
|
824
|
-
"""Wrap a translated script body so a top-level `return` (common in
|
|
825
|
-
BeanShell/JSR223) is legal JavaScript and local decls don't leak."""
|
|
826
|
-
body = [(' ' + ln) if ln else ln for ln in lines]
|
|
827
|
-
return ['(function () {'] + body + ['})();']
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def extractor_to_js(ext, manual_tasks, req_name):
|
|
831
|
-
"""Return list of JS lines for one extractor (post-response)."""
|
|
832
|
-
lines = []
|
|
833
|
-
k = ext['kind']
|
|
834
|
-
if k == 'regex':
|
|
835
|
-
ref = ext['ref'] or 'extracted'
|
|
836
|
-
if not ext['regex']:
|
|
837
|
-
return [f'// [skipped] empty regex extractor "{ref}"']
|
|
838
|
-
grp = re.findall(r'\$(\d+)\$', ext.get('template', '$1$'))
|
|
839
|
-
gi = grp[0] if grp else '1'
|
|
840
|
-
src = 'pm.response.headers.toString()' if ext.get('from_headers') else 'pm.response.text()'
|
|
841
|
-
pat = _escape_js(ext['regex'])
|
|
842
|
-
dflt = _escape_js(ext.get('default', ''))
|
|
843
|
-
ref_e = _escape_js(ref)
|
|
844
|
-
lines.append(f'try {{ var _m = String({src}).match(new RegExp("{pat}")); pm.collectionVariables.set("{ref_e}", _m ? _m[{gi}] : "{dflt}"); }} catch (e) {{ pm.collectionVariables.set("{ref_e}", "{dflt}"); }}')
|
|
845
|
-
elif k == 'boundary':
|
|
846
|
-
ref = ext['ref'] or 'extracted'
|
|
847
|
-
lb = re.escape(ext['lboundary']) if ext['lboundary'] else ''
|
|
848
|
-
rb = re.escape(ext['rboundary']) if ext['rboundary'] else ''
|
|
849
|
-
if not lb and not rb:
|
|
850
|
-
return [f'// [skipped] boundary extractor "{ref}" has no boundaries']
|
|
851
|
-
src = 'pm.response.headers.toString()' if ext.get('from_headers') else 'pm.response.text()'
|
|
852
|
-
pat = _escape_js(lb + '([\\s\\S]*?)' + rb)
|
|
853
|
-
dflt = _escape_js(ext.get('default', ''))
|
|
854
|
-
ref_e = _escape_js(ref)
|
|
855
|
-
lines.append(f'try {{ var _b = String({src}).match(new RegExp("{pat}")); pm.collectionVariables.set("{ref_e}", _b ? _b[1] : "{dflt}"); }} catch (e) {{ pm.collectionVariables.set("{ref_e}", "{dflt}"); }}')
|
|
856
|
-
elif k == 'jsonpath_multi':
|
|
857
|
-
for ref, expr, dflt in zip(ext['refs'], ext['exprs'], ext['defaults'] + [''] * len(ext['refs'])):
|
|
858
|
-
access = _jsonpath_to_js(expr, manual_tasks, req_name, ref)
|
|
859
|
-
if access is None:
|
|
860
|
-
lines.append(f'// [manual] JSONPath "{expr}" -> set "{ref}"; too complex for auto-import')
|
|
861
|
-
else:
|
|
862
|
-
lines.append(f'try {{ pm.collectionVariables.set("{_escape_js(ref)}", {access}); }} catch (e) {{ pm.collectionVariables.set("{_escape_js(ref)}", "{_escape_js(dflt)}"); }}')
|
|
863
|
-
elif k == 'jsonpath_single':
|
|
864
|
-
ref = ext['ref'] or 'extracted'
|
|
865
|
-
access = _jsonpath_to_js(ext['expr'], manual_tasks, req_name, ref)
|
|
866
|
-
if access is None:
|
|
867
|
-
lines.append(f'// [manual] JSONPath "{ext["expr"]}" -> set "{ref}"; too complex for auto-import')
|
|
868
|
-
else:
|
|
869
|
-
lines.append(f'try {{ pm.collectionVariables.set("{_escape_js(ref)}", {access}); }} catch (e) {{ pm.collectionVariables.set("{_escape_js(ref)}", "{_escape_js(ext.get("default",""))}"); }}')
|
|
870
|
-
elif k in ('xpath', 'css'):
|
|
871
|
-
ref = ext.get('ref') or 'extracted'
|
|
872
|
-
manual_tasks.append({'request': req_name, 'kind': k,
|
|
873
|
-
'detail': f'{k.upper()} extractor "{ref}" ({ext.get("query") or ext.get("expr")}) needs manual import; Postman sandbox has no built-in {k.upper()} support.'})
|
|
874
|
-
lines.append(f'// [manual] {k.upper()} extractor "{ref}" — see import report')
|
|
875
|
-
return lines
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
def _jsonpath_to_js(expr, manual_tasks, req_name, ref):
|
|
879
|
-
"""Import simple JSONPath ($.a.b[0].c) into a JS accessor on the parsed body.
|
|
880
|
-
Returns None for anything with filters/wildcards/recursion."""
|
|
881
|
-
if not expr:
|
|
882
|
-
return None
|
|
883
|
-
e = expr.strip()
|
|
884
|
-
if e in ('$', '$.'):
|
|
885
|
-
return 'pm.response.json()'
|
|
886
|
-
if not e.startswith('$'):
|
|
887
|
-
return None
|
|
888
|
-
if any(tok in e for tok in ['*', '..', '?', '(', ')', '@', ',', ':']):
|
|
889
|
-
return None
|
|
890
|
-
body = e[1:]
|
|
891
|
-
if body.startswith('.'):
|
|
892
|
-
body = body[1:]
|
|
893
|
-
# split into dot/bracket segments
|
|
894
|
-
js = 'pm.response.json()'
|
|
895
|
-
for seg in re.split(r'\.(?![^\[]*\])', body):
|
|
896
|
-
if not seg:
|
|
897
|
-
continue
|
|
898
|
-
m = re.match(r'^(\w+)((\[\d+\])*)$', seg)
|
|
899
|
-
if not m:
|
|
900
|
-
return None
|
|
901
|
-
js += '.' + m.group(1)
|
|
902
|
-
if m.group(2):
|
|
903
|
-
js += m.group(2)
|
|
904
|
-
return js
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
def assertion_to_js(asrt, idx, req_name):
|
|
908
|
-
"""Return (js_lines, pass_if_hint_or_None)."""
|
|
909
|
-
k = asrt['kind']
|
|
910
|
-
if k == 'response':
|
|
911
|
-
return _response_assertion_js(asrt, idx), None
|
|
912
|
-
if k == 'duration':
|
|
913
|
-
ms = (asrt.get('ms') or '').strip()
|
|
914
|
-
if ms.isdigit():
|
|
915
|
-
js = [f'pm.test("Response time < {ms}ms", function () {{', f' pm.expect(pm.response.responseTime).to.be.below({ms});', '});']
|
|
916
|
-
hint = {'metric': 'p95', 'op': 'less_than', 'value': ms}
|
|
917
|
-
else:
|
|
918
|
-
# a variable-driven threshold (e.g. ${max_ms}) must not be emitted into JS
|
|
919
|
-
js = [f'// [manual] Duration Assertion threshold "{ms}" is not a literal number; add a numeric pm.response.responseTime check by hand.']
|
|
920
|
-
hint = None
|
|
921
|
-
return js, hint
|
|
922
|
-
if k == 'size':
|
|
923
|
-
return [f'pm.test("Response size check", function () {{',
|
|
924
|
-
f' pm.expect(pm.response.responseSize).to.be.above(0); // JMeter SizeAssertion {asrt.get("op")} {asrt.get("bytes")}', '});'], None
|
|
925
|
-
if k == 'jsonpath':
|
|
926
|
-
path = asrt.get('path', '')
|
|
927
|
-
access = _jsonpath_to_js(path, [], req_name, 'x')
|
|
928
|
-
if access:
|
|
929
|
-
exp = asrt.get('expected')
|
|
930
|
-
if exp:
|
|
931
|
-
return [f'pm.test("JSONPath {path} == {exp}", function () {{',
|
|
932
|
-
f' pm.expect(String({access})).to.eql("{_escape_js(exp)}");', '});'], None
|
|
933
|
-
return [f'pm.test("JSONPath {path} exists", function () {{', f' pm.expect({access}).to.not.be.undefined;', '});'], None
|
|
934
|
-
return [f'// [manual] JSONPath assertion "{path}" needs manual import'], None
|
|
935
|
-
return [], None
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
def _response_assertion_js(a, idx):
|
|
939
|
-
t = a['test_type']
|
|
940
|
-
field = a['field']
|
|
941
|
-
negate = bool(t & F_NOT)
|
|
942
|
-
if field == 'Assertion.response_code':
|
|
943
|
-
getter = 'pm.response.code'
|
|
944
|
-
is_code = True
|
|
945
|
-
elif field == 'Assertion.response_message':
|
|
946
|
-
getter, is_code = 'pm.response.status', False
|
|
947
|
-
elif field == 'Assertion.response_headers':
|
|
948
|
-
getter, is_code = 'pm.response.headers.toString()', False
|
|
949
|
-
else:
|
|
950
|
-
getter, is_code = 'pm.response.text()', False
|
|
951
|
-
|
|
952
|
-
lines = []
|
|
953
|
-
for s in a['strings']:
|
|
954
|
-
label = _escape_js(s)[:40]
|
|
955
|
-
if t & F_EQUALS:
|
|
956
|
-
if is_code:
|
|
957
|
-
expr = f'pm.expect(pm.response.code){".not" if negate else ""}.to.eql({_to_int(s)})'
|
|
958
|
-
else:
|
|
959
|
-
expr = f'pm.expect({getter}){".not" if negate else ""}.to.eql("{_escape_js(s)}")'
|
|
960
|
-
name = f'status is {s}' if is_code else f'equals {label}'
|
|
961
|
-
elif t & F_MATCH or t & F_CONTAINS:
|
|
962
|
-
expr = f'pm.expect(String({getter})){".not" if negate else ""}.to.match(new RegExp("{_escape_js(s)}"))'
|
|
963
|
-
name = f'matches /{label}/'
|
|
964
|
-
else: # SUBSTRING (16) or default -> plain contains
|
|
965
|
-
val = f'String({getter})' if is_code else getter
|
|
966
|
-
expr = f'pm.expect({val}){".not" if negate else ""}.to.include("{_escape_js(s)}")'
|
|
967
|
-
name = f'contains {label}'
|
|
968
|
-
lines.append(f'pm.test("{name}", function () {{')
|
|
969
|
-
lines.append(f' {expr};')
|
|
970
|
-
lines.append('});')
|
|
971
|
-
return lines
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
def build_request_scripts(req, manual_tasks):
|
|
975
|
-
"""Assemble prerequest + test event scripts for one request."""
|
|
976
|
-
pre_lines, test_lines = [], []
|
|
977
|
-
pass_if_hints = []
|
|
978
|
-
|
|
979
|
-
# If-Controller guards run first: skip the request when the condition is false.
|
|
980
|
-
# Only inline conditions we can prove are valid JS; otherwise comment + flag,
|
|
981
|
-
# so a JMeter placeholder/expression never breaks the whole script.
|
|
982
|
-
for cond in req.get('guards', []):
|
|
983
|
-
if _safe_condition(cond):
|
|
984
|
-
pre_lines.append('// If Controller guard (imported from JMeter) — review condition')
|
|
985
|
-
pre_lines.append(f'if (!({cond})) {{ pm.execution.skipRequest(); }}')
|
|
986
|
-
else:
|
|
987
|
-
pre_lines.append(f'// [manual] If Controller condition could not be safely imported: {cond}')
|
|
988
|
-
pre_lines.append('// Guard this request with pm.execution.skipRequest() once the condition is rewritten.')
|
|
989
|
-
manual_tasks.append({'request': req['name'], 'kind': 'if_condition',
|
|
990
|
-
'detail': f'If Controller condition "{cond}" is not valid JS; add a pm.execution.skipRequest() guard manually.'})
|
|
991
|
-
|
|
992
|
-
for sc in req.get('pre_scripts', []):
|
|
993
|
-
tr = groovy_translate(sc['script'], sc.get('lang', 'groovy'))
|
|
994
|
-
pre_lines.append(f'// --- imported from JMeter {sc.get("lang","")} pre-processor "{sc.get("name","")}" (REVIEW) ---')
|
|
995
|
-
pre_lines.extend(_wrap_iife(tr['js'].splitlines()))
|
|
996
|
-
manual_tasks.append({'request': req['name'], 'kind': 'script',
|
|
997
|
-
'detail': f'Pre-processor script ({sc.get("lang")}) auto-translated; review: ' + '; '.join(tr['reasons']) if tr['reasons'] else f'Pre-processor script ({sc.get("lang")}) auto-translated; review for fidelity.'})
|
|
998
|
-
|
|
999
|
-
# JMeter fails a sample on HTTP >= 400 even with no assertion. Reproduce that
|
|
1000
|
-
# default success check unless the plan already checks the status code or
|
|
1001
|
-
# explicitly ignores status (assume_success / "Ignore Status").
|
|
1002
|
-
asserts = req.get('assertions', [])
|
|
1003
|
-
has_status = any(a.get('kind') == 'response' and a.get('field') == 'Assertion.response_code' for a in asserts)
|
|
1004
|
-
ignore_status = any(a.get('kind') == 'response' and a.get('assume_success') for a in asserts)
|
|
1005
|
-
if not has_status and not ignore_status:
|
|
1006
|
-
test_lines.append("pm.test('JMeter default success (HTTP 200-399)', function () {")
|
|
1007
|
-
test_lines.append(' pm.expect(pm.response.code).to.be.within(200, 399);')
|
|
1008
|
-
test_lines.append('});')
|
|
1009
|
-
|
|
1010
|
-
for ext in req.get('extractors', []):
|
|
1011
|
-
test_lines.extend(extractor_to_js(ext, manual_tasks, req['name']))
|
|
1012
|
-
|
|
1013
|
-
for i, asrt in enumerate(req.get('assertions', [])):
|
|
1014
|
-
js, hint = assertion_to_js(asrt, i, req['name'])
|
|
1015
|
-
test_lines.extend(js)
|
|
1016
|
-
if hint:
|
|
1017
|
-
pass_if_hints.append(hint)
|
|
1018
|
-
|
|
1019
|
-
for sc in req.get('post_scripts', []):
|
|
1020
|
-
tr = groovy_translate(sc['script'], sc.get('lang', 'groovy'))
|
|
1021
|
-
test_lines.append(f'// --- imported from JMeter {sc.get("lang","")} post-processor "{sc.get("name","")}" (REVIEW) ---')
|
|
1022
|
-
test_lines.extend(_wrap_iife(tr['js'].splitlines()))
|
|
1023
|
-
manual_tasks.append({'request': req['name'], 'kind': 'script',
|
|
1024
|
-
'detail': f'Post-processor script ({sc.get("lang")}) auto-translated; review for fidelity.'})
|
|
1025
|
-
|
|
1026
|
-
events = []
|
|
1027
|
-
if pre_lines:
|
|
1028
|
-
events.append({'listen': 'prerequest', 'script': {'type': 'text/javascript', 'exec': pre_lines}})
|
|
1029
|
-
if test_lines:
|
|
1030
|
-
events.append({'listen': 'test', 'script': {'type': 'text/javascript', 'exec': test_lines}})
|
|
1031
|
-
return events, pass_if_hints
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
def build_url(u):
|
|
1035
|
-
# JMeter treats port 0 / -1 as "use the protocol default" -> omit it.
|
|
1036
|
-
port = u['port'] if u['port'] not in ('', '0', '-1') else ''
|
|
1037
|
-
path = u['path'] or ''
|
|
1038
|
-
# The path field may itself be a full URL; use it verbatim and ignore host/port.
|
|
1039
|
-
if path.startswith('http://') or path.startswith('https://'):
|
|
1040
|
-
raw = path
|
|
1041
|
-
if u['query']:
|
|
1042
|
-
raw += ('&' if '?' in raw else '?') + '&'.join(f'{q["key"]}={q["value"]}' for q in u['query'])
|
|
1043
|
-
url = {'raw': raw}
|
|
1044
|
-
if u['query']:
|
|
1045
|
-
url['query'] = [{'key': q['key'], 'value': q['value']} for q in u['query']]
|
|
1046
|
-
return url
|
|
1047
|
-
host = u['domain'].split('.') if u['domain'] else []
|
|
1048
|
-
raw_scheme = (u['protocol'] + '://') if u['protocol'] else ''
|
|
1049
|
-
raw_host = u['domain']
|
|
1050
|
-
raw_port = (':' + port) if port else ''
|
|
1051
|
-
raw_path = path if path.startswith('/') or not raw_host else '/' + path
|
|
1052
|
-
raw = f'{raw_scheme}{raw_host}{raw_port}{raw_path}'
|
|
1053
|
-
if u['query']:
|
|
1054
|
-
raw += '?' + '&'.join(f'{q["key"]}={q["value"]}' for q in u['query'])
|
|
1055
|
-
url = {'raw': raw}
|
|
1056
|
-
if u['protocol']:
|
|
1057
|
-
url['protocol'] = u['protocol']
|
|
1058
|
-
if host:
|
|
1059
|
-
url['host'] = host
|
|
1060
|
-
if port:
|
|
1061
|
-
url['port'] = port
|
|
1062
|
-
seg = [p for p in path.split('/') if p != '']
|
|
1063
|
-
if seg:
|
|
1064
|
-
url['path'] = seg
|
|
1065
|
-
if u['query']:
|
|
1066
|
-
url['query'] = [{'key': q['key'], 'value': q['value']} for q in u['query']]
|
|
1067
|
-
return url
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
def build_auth(auth):
|
|
1071
|
-
if not auth:
|
|
1072
|
-
return None
|
|
1073
|
-
if auth['type'] == 'basic':
|
|
1074
|
-
return {'type': 'basic', 'basic': [{'key': 'username', 'value': auth['username'], 'type': 'string'},
|
|
1075
|
-
{'key': 'password', 'value': auth['password'], 'type': 'string'}]}
|
|
1076
|
-
if auth['type'] == 'digest':
|
|
1077
|
-
return {'type': 'digest', 'digest': [{'key': 'username', 'value': auth['username']},
|
|
1078
|
-
{'key': 'password', 'value': auth['password']}]}
|
|
1079
|
-
return None
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
def build_item(entry, manual_tasks, pass_if_hints):
|
|
1083
|
-
if entry['type'] == 'folder':
|
|
1084
|
-
children = [build_item(c, manual_tasks, pass_if_hints) for c in entry['items']]
|
|
1085
|
-
return {'name': entry['name'], 'item': children}
|
|
1086
|
-
if entry['type'] == 'script_request':
|
|
1087
|
-
sc = entry['script']
|
|
1088
|
-
tr = groovy_translate(sc['script'], sc.get('lang', 'groovy'))
|
|
1089
|
-
manual_tasks.append({'request': sc['name'], 'kind': 'script_sampler',
|
|
1090
|
-
'detail': f'Script sampler ({sc.get("lang")}) has no HTTP call. Replace this stub with a real request or move logic into a pre/post script.'})
|
|
1091
|
-
stub = [f'// --- JMeter {sc.get("lang","")} script sampler "{sc.get("name","")}" — no HTTP call (REVIEW) ---'] + _wrap_iife(tr['js'].splitlines())
|
|
1092
|
-
return {'name': sc['name'] or 'Script Step',
|
|
1093
|
-
'event': [{'listen': 'prerequest', 'script': {'type': 'text/javascript', 'exec': stub}}],
|
|
1094
|
-
'request': {'method': 'GET', 'header': [], 'url': {'raw': ''}}}
|
|
1095
|
-
# request
|
|
1096
|
-
req = entry['req']
|
|
1097
|
-
events, hints = build_request_scripts(req, manual_tasks)
|
|
1098
|
-
pass_if_hints.extend(hints)
|
|
1099
|
-
item = {'name': req['name']}
|
|
1100
|
-
if events:
|
|
1101
|
-
item['event'] = events
|
|
1102
|
-
request = {'method': req['method'],
|
|
1103
|
-
'header': [{'key': h['key'], 'value': h['value']} for h in req['headers']],
|
|
1104
|
-
'url': build_url(req['url'])}
|
|
1105
|
-
if req['body']:
|
|
1106
|
-
request['body'] = req['body']
|
|
1107
|
-
auth = build_auth(req['auth'])
|
|
1108
|
-
if auth:
|
|
1109
|
-
request['auth'] = auth
|
|
1110
|
-
item['request'] = request
|
|
1111
|
-
if req.get('timers'):
|
|
1112
|
-
item['description'] = 'NOTE: JMeter think-time timers (' + ', '.join(req['timers']) + ') were dropped — Postman performance runs have no think time.'
|
|
1113
|
-
return item
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
def _assemble_items(tgs, manual_tasks, pass_if_hints):
|
|
1117
|
-
"""Build the Postman item array from a list of thread groups.
|
|
1118
|
-
One thread group -> its requests inline; several -> one folder each."""
|
|
1119
|
-
items = []
|
|
1120
|
-
if len(tgs) == 1:
|
|
1121
|
-
for entry in tgs[0]['items']:
|
|
1122
|
-
items.append(build_item(entry, manual_tasks, pass_if_hints))
|
|
1123
|
-
else:
|
|
1124
|
-
for tg in tgs:
|
|
1125
|
-
folder = {'name': tg['load']['name'], 'item': []}
|
|
1126
|
-
for entry in tg['items']:
|
|
1127
|
-
folder['item'].append(build_item(entry, manual_tasks, pass_if_hints))
|
|
1128
|
-
items.append(folder)
|
|
1129
|
-
return items
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
def _make_collection(name, items, variables):
|
|
1133
|
-
coll = {
|
|
1134
|
-
'info': {'name': name, 'schema': SCHEMA_V21, '_postman_id': _stable_id(name)},
|
|
1135
|
-
'item': items,
|
|
1136
|
-
}
|
|
1137
|
-
if variables:
|
|
1138
|
-
coll['variable'] = [{'key': k, 'value': v} for k, v in variables.items()]
|
|
1139
|
-
return coll
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
def build_collection(plan, manual_tasks):
|
|
1143
|
-
"""Return (main_collection, pass_if_hints, extras).
|
|
1144
|
-
|
|
1145
|
-
setUp/tearDown thread groups are severed into their own collections so a
|
|
1146
|
-
performance run never loops them per virtual user; they are returned in
|
|
1147
|
-
`extras` keyed 'setup'/'teardown' as {'collection': <dict>, 'requests': [...]}.
|
|
1148
|
-
"""
|
|
1149
|
-
pass_if_hints = []
|
|
1150
|
-
by_role = {'main': [], 'setup': [], 'teardown': []}
|
|
1151
|
-
for tg in plan['thread_groups']:
|
|
1152
|
-
by_role.get(tg['load'].get('role', 'main'), by_role['main']).append(tg)
|
|
1153
|
-
# Nothing explicitly 'main' (e.g. a plan that is only setUp groups): treat all as main.
|
|
1154
|
-
main_tgs = by_role['main'] or plan['thread_groups']
|
|
1155
|
-
|
|
1156
|
-
items = _assemble_items(main_tgs, manual_tasks, pass_if_hints)
|
|
1157
|
-
coll = _make_collection(plan['name'], items, plan['variables'])
|
|
1158
|
-
|
|
1159
|
-
extras = {}
|
|
1160
|
-
for role in ('setup', 'teardown'):
|
|
1161
|
-
if by_role[role] and by_role['main']:
|
|
1162
|
-
sub_items = _assemble_items(by_role[role], manual_tasks, [])
|
|
1163
|
-
suffix = ' (Setup)' if role == 'setup' else ' (Teardown)'
|
|
1164
|
-
sub_coll = _make_collection(plan['name'] + suffix, sub_items, plan['variables'])
|
|
1165
|
-
extras[role] = {'collection': sub_coll,
|
|
1166
|
-
'requests': [it['name'] for it in walk_item_names(sub_items)]}
|
|
1167
|
-
return coll, pass_if_hints, extras
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
def walk_item_names(items):
|
|
1171
|
-
for it in items:
|
|
1172
|
-
if 'item' in it:
|
|
1173
|
-
yield from walk_item_names(it['item'])
|
|
1174
|
-
else:
|
|
1175
|
-
yield it
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
def _stable_id(seed):
|
|
1179
|
-
import hashlib
|
|
1180
|
-
h = hashlib.md5((seed or 'jmx').encode()).hexdigest()
|
|
1181
|
-
return f'{h[0:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:32]}'
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
# ===========================================================================
|
|
1185
|
-
# PERF CONFIG
|
|
1186
|
-
# ===========================================================================
|
|
1187
|
-
|
|
1188
|
-
def build_perf_config(plan, pass_if_hints, extras=None, base=None):
|
|
1189
|
-
# A performance run loads the main thread groups only; setUp/tearDown run
|
|
1190
|
-
# once per lifecycle via separate collections (see `extras`).
|
|
1191
|
-
tgs = [tg for tg in plan['thread_groups'] if tg['load'].get('role', 'main') == 'main']
|
|
1192
|
-
if not tgs:
|
|
1193
|
-
tgs = plan['thread_groups']
|
|
1194
|
-
runs = []
|
|
1195
|
-
for tg in tgs:
|
|
1196
|
-
load = tg['load']
|
|
1197
|
-
vus = max(load['vus'], 1)
|
|
1198
|
-
# duration: prefer scheduler duration; else approximate from loops
|
|
1199
|
-
if load['duration_s']:
|
|
1200
|
-
dur_min = max(1, round(load['duration_s'] / 60))
|
|
1201
|
-
dur_note = f'from JMeter scheduler duration {load["duration_s"]}s'
|
|
1202
|
-
else:
|
|
1203
|
-
dur_min = 1
|
|
1204
|
-
if load['loops'] == -1:
|
|
1205
|
-
dur_note = 'JMeter looped forever; defaulted to 1 min — set your own duration'
|
|
1206
|
-
elif load['loops'] and load['loops'] > 0:
|
|
1207
|
-
dur_note = f'JMeter ran {load["loops"]} loop(s) (iteration count); Postman runs by time, defaulted to 1 min'
|
|
1208
|
-
else:
|
|
1209
|
-
dur_note = 'no duration in JMeter; defaulted to 1 min'
|
|
1210
|
-
# profile
|
|
1211
|
-
if load['kind'] in ('concurrency', 'stepping', 'ultimate') or load['ramp_s'] > 0:
|
|
1212
|
-
profile = 'ramp-up'
|
|
1213
|
-
else:
|
|
1214
|
-
profile = 'fixed'
|
|
1215
|
-
runs.append({'name': load['name'], 'vus': vus, 'duration_min': dur_min,
|
|
1216
|
-
'load_profile': profile, 'duration_note': dur_note,
|
|
1217
|
-
'source_kind': load['kind']})
|
|
1218
|
-
|
|
1219
|
-
# pass-if: pick the single tightest response-time hint if any; else a safe default
|
|
1220
|
-
pass_if = None
|
|
1221
|
-
rt_hints = [h for h in pass_if_hints if h['metric'] in ('p95', 'avg')]
|
|
1222
|
-
if rt_hints:
|
|
1223
|
-
tightest = min(rt_hints, key=lambda h: _to_int(h['value'], 1 << 30))
|
|
1224
|
-
pass_if = f'{tightest["op"]}({tightest["metric"]}, {tightest["value"]})'
|
|
1225
|
-
cfg = {
|
|
1226
|
-
'runs': runs,
|
|
1227
|
-
'pass_if': pass_if,
|
|
1228
|
-
'pass_if_suggested': pass_if or 'less_than(error_rate, 5)',
|
|
1229
|
-
'data_files': [{'filename': ds['filename'], 'columns': ds['variableNames']} for ds in plan['data_sets']],
|
|
1230
|
-
'notes': [],
|
|
1231
|
-
}
|
|
1232
|
-
if len(runs) > 1:
|
|
1233
|
-
cfg['notes'].append('JMeter had multiple thread groups. Postman performance runs execute ONE collection repeated across VUs; they cannot run multiple thread groups concurrently. Each thread group is listed as a separate suggested run — run them individually, or merge into one collection and pick a combined VU/duration.')
|
|
1234
|
-
if len(plan['data_sets']) > 1:
|
|
1235
|
-
cfg['notes'].append('Multiple CSV Data Sets found. A performance run takes a single --data-file (or one --dataset-id). Merge the CSVs or choose the primary one.')
|
|
1236
|
-
|
|
1237
|
-
# setUp / tearDown collections run once per lifecycle, passed to the runner
|
|
1238
|
-
# as --setup / --teardown collections rather than looped per VU.
|
|
1239
|
-
extras = extras or {}
|
|
1240
|
-
for role in ('setup', 'teardown'):
|
|
1241
|
-
if role in extras:
|
|
1242
|
-
fname = f'{base}.{role}.postman_collection.json' if base else f'{role}.postman_collection.json'
|
|
1243
|
-
cfg[f'{role}_run'] = {'collection': fname, 'requests': extras[role]['requests']}
|
|
1244
|
-
cfg['notes'].append(
|
|
1245
|
-
f'{role.capitalize()} thread group severed into {fname}; it runs ONCE per run '
|
|
1246
|
-
f'(not looped by every virtual user). Pass it to the runner as the {role} collection.')
|
|
1247
|
-
return cfg
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
def render_perf_md(cfg, collection_filename):
|
|
1251
|
-
lines = ['# Performance Test Configuration', '']
|
|
1252
|
-
for i, run in enumerate(cfg['runs'], 1):
|
|
1253
|
-
lines.append(f'## Run {i}: {run["name"]}')
|
|
1254
|
-
lines.append('')
|
|
1255
|
-
lines.append(f'- Virtual users: **{run["vus"]}**')
|
|
1256
|
-
lines.append(f'- Duration: **{run["duration_min"]} min** ({run["duration_note"]})')
|
|
1257
|
-
lines.append(f'- Load profile: **{run["load_profile"]}** (from JMeter {run["source_kind"]} thread group)')
|
|
1258
|
-
lines.append('')
|
|
1259
|
-
cmd = f'postman performance run <COLLECTION_UID> \\\n --vu-count {run["vus"]} \\\n --duration {run["duration_min"]} \\\n --load-profile {run["load_profile"]}'
|
|
1260
|
-
if cfg['data_files']:
|
|
1261
|
-
cmd += f' \\\n --data-file "{cfg["data_files"][0]["filename"]}"'
|
|
1262
|
-
if cfg['pass_if']:
|
|
1263
|
-
cmd += f' \\\n --pass-if "{cfg["pass_if"]}"'
|
|
1264
|
-
else:
|
|
1265
|
-
cmd += f' \\\n --pass-if "{cfg["pass_if_suggested"]}" # suggested default — no JMeter threshold found'
|
|
1266
|
-
lines.append('```bash')
|
|
1267
|
-
lines.append(cmd)
|
|
1268
|
-
lines.append('```')
|
|
1269
|
-
lines.append('')
|
|
1270
|
-
if cfg['data_files']:
|
|
1271
|
-
lines.append('## Data files')
|
|
1272
|
-
for df in cfg['data_files']:
|
|
1273
|
-
lines.append(f'- `{df["filename"]}` — columns: {", ".join(df["columns"]) or "(unknown)"}')
|
|
1274
|
-
lines.append('')
|
|
1275
|
-
if cfg['notes']:
|
|
1276
|
-
lines.append('## Notes')
|
|
1277
|
-
for n in cfg['notes']:
|
|
1278
|
-
lines.append(f'- {n}')
|
|
1279
|
-
lines.append('')
|
|
1280
|
-
return '\n'.join(lines)
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
# ===========================================================================
|
|
1284
|
-
# REPORT
|
|
1285
|
-
# ===========================================================================
|
|
1286
|
-
|
|
1287
|
-
def count_items(items):
|
|
1288
|
-
reqs = folders = scripts = 0
|
|
1289
|
-
for it in items:
|
|
1290
|
-
if it['type'] == 'folder':
|
|
1291
|
-
r, f, s = count_items(it['items'])
|
|
1292
|
-
folders += 1 + f
|
|
1293
|
-
reqs += r
|
|
1294
|
-
scripts += s
|
|
1295
|
-
elif it['type'] == 'script_request':
|
|
1296
|
-
scripts += 1
|
|
1297
|
-
else:
|
|
1298
|
-
reqs += 1
|
|
1299
|
-
return reqs, folders, scripts
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
def render_report(plan, cfg, manual_tasks, collection_name):
|
|
1303
|
-
total_reqs = total_folders = total_scripts = 0
|
|
1304
|
-
for tg in plan['thread_groups']:
|
|
1305
|
-
r, f, s = count_items(tg['items'])
|
|
1306
|
-
total_reqs += r
|
|
1307
|
-
total_folders += f
|
|
1308
|
-
total_scripts += s
|
|
1309
|
-
|
|
1310
|
-
lines = [f'# Import Report — {plan["name"]}', '']
|
|
1311
|
-
lines.append('## Summary')
|
|
1312
|
-
lines.append('')
|
|
1313
|
-
lines.append(f'- Thread groups: {len(plan["thread_groups"])}')
|
|
1314
|
-
lines.append(f'- HTTP requests imported: {total_reqs}')
|
|
1315
|
-
lines.append(f'- Folders: {total_folders}')
|
|
1316
|
-
lines.append(f'- Script-only samplers (stubs): {total_scripts}')
|
|
1317
|
-
lines.append(f'- Collection variables: {len(plan["variables"])}')
|
|
1318
|
-
lines.append(f'- Data files: {len(plan["data_sets"])}')
|
|
1319
|
-
lines.append(f'- Items needing manual work: {len(manual_tasks)}')
|
|
1320
|
-
lines.append('')
|
|
1321
|
-
|
|
1322
|
-
lines.append('## Imported exactly')
|
|
1323
|
-
lines.append('')
|
|
1324
|
-
lines.append('- HTTP requests (method, URL, headers, query/body params)')
|
|
1325
|
-
lines.append('- Response assertions → `pm.test` (status / body contains / equals / matches)')
|
|
1326
|
-
lines.append('- Regex & Boundary extractors → `pm.collectionVariables.set`')
|
|
1327
|
-
lines.append('- Simple JSONPath extractors (dot/index paths) → property access')
|
|
1328
|
-
lines.append('- Basic/Digest auth, User Defined Variables → collection variables')
|
|
1329
|
-
lines.append('- `${var}` → `{{var}}` interpolation')
|
|
1330
|
-
lines.append('')
|
|
1331
|
-
|
|
1332
|
-
lines.append('## Approximated')
|
|
1333
|
-
lines.append('')
|
|
1334
|
-
lines.append('- **Thread group load** → VU count + duration + one of 4 load profiles (JMeter custom ramp curves are not reproducible; closest profile chosen).')
|
|
1335
|
-
lines.append('- **Loop counts / iterations** → duration in minutes (Postman runs by time, not iteration count).')
|
|
1336
|
-
lines.append('- **JMeter functions → Postman dynamic variables** are approximate: `__time` is epoch-ms in JMeter vs epoch-s for `{{$timestamp}}`; `__Random`/`__RandomString` lose their argument bounds. Verify where exact values matter.')
|
|
1337
|
-
for w in plan['warnings']:
|
|
1338
|
-
lines.append(f'- {w}')
|
|
1339
|
-
lines.append('')
|
|
1340
|
-
|
|
1341
|
-
lines.append('## Needs manual work')
|
|
1342
|
-
lines.append('')
|
|
1343
|
-
if manual_tasks:
|
|
1344
|
-
for mt in manual_tasks:
|
|
1345
|
-
lines.append(f'- **[{mt["kind"]}]** `{mt["request"]}`: {mt["detail"]}')
|
|
1346
|
-
else:
|
|
1347
|
-
lines.append('- None 🎉')
|
|
1348
|
-
lines.append('')
|
|
1349
|
-
|
|
1350
|
-
lines.append('## Not supported by Postman performance runs (dropped)')
|
|
1351
|
-
lines.append('')
|
|
1352
|
-
lines.append('- **Think time / timers** (Constant, Uniform, Gaussian, Throughput Shaping): no equivalent.')
|
|
1353
|
-
lines.append('- **Multiple concurrent thread groups**: a run is one collection repeated across VUs.')
|
|
1354
|
-
lines.append('- **Flow control** (If/While Controllers): requests were flattened; restore with `pm.execution.setNextRequest`/`skipRequest` if needed.')
|
|
1355
|
-
lines.append('- **Cookie/Cache/DNS managers**: cookies are handled automatically per VU; preset values not transferred.')
|
|
1356
|
-
lines.append('')
|
|
1357
|
-
return '\n'.join(lines)
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
# ===========================================================================
|
|
1361
|
-
# MAIN
|
|
1362
|
-
# ===========================================================================
|
|
1363
|
-
|
|
1364
|
-
def main():
|
|
1365
|
-
ap = argparse.ArgumentParser(description='Import a JMeter .jmx file to a Postman collection + perf config.')
|
|
1366
|
-
ap.add_argument('input', help='path to the .jmx file')
|
|
1367
|
-
ap.add_argument('-o', '--outdir', default='.', help='output directory')
|
|
1368
|
-
ap.add_argument('--name', help='override the collection name and output base filename')
|
|
1369
|
-
ap.add_argument('--json-report', action='store_true', help='also emit the raw IR and manual tasks as JSON')
|
|
1370
|
-
ap.add_argument('--fail-on-blockers', action='store_true', help='exit non-zero if any item needs manual work (CI gate)')
|
|
1371
|
-
args = ap.parse_args()
|
|
1372
|
-
|
|
1373
|
-
try:
|
|
1374
|
-
plan = parse_jmx(args.input)
|
|
1375
|
-
except ET.ParseError as e:
|
|
1376
|
-
print(f'ERROR {args.input}: malformed JMeter XML — {e}', file=sys.stderr)
|
|
1377
|
-
sys.exit(2)
|
|
1378
|
-
except (ValueError, KeyError) as e:
|
|
1379
|
-
print(f'ERROR {args.input}: could not import — {e}', file=sys.stderr)
|
|
1380
|
-
sys.exit(2)
|
|
1381
|
-
base = args.name or os.path.splitext(os.path.basename(args.input))[0]
|
|
1382
|
-
if args.name:
|
|
1383
|
-
plan['name'] = args.name # --name overrides the collection name, not just the filenames
|
|
1384
|
-
os.makedirs(args.outdir, exist_ok=True)
|
|
1385
|
-
|
|
1386
|
-
manual_tasks = []
|
|
1387
|
-
try:
|
|
1388
|
-
collection, pass_if_hints, extras = build_collection(plan, manual_tasks)
|
|
1389
|
-
cfg = build_perf_config(plan, pass_if_hints, extras, base)
|
|
1390
|
-
except Exception as e: # build-phase failure: fail cleanly, never a stack trace
|
|
1391
|
-
print(f'ERROR {args.input}: import failed in build phase — {type(e).__name__}: {e}', file=sys.stderr)
|
|
1392
|
-
sys.exit(2)
|
|
1393
|
-
|
|
1394
|
-
coll_path = os.path.join(args.outdir, f'{base}.postman_collection.json')
|
|
1395
|
-
with open(coll_path, 'w') as f:
|
|
1396
|
-
json.dump(collection, f, indent=2)
|
|
1397
|
-
|
|
1398
|
-
for role in ('setup', 'teardown'):
|
|
1399
|
-
if role in extras:
|
|
1400
|
-
with open(os.path.join(args.outdir, f'{base}.{role}.postman_collection.json'), 'w') as f:
|
|
1401
|
-
json.dump(extras[role]['collection'], f, indent=2)
|
|
1402
|
-
|
|
1403
|
-
perf_json_path = os.path.join(args.outdir, f'{base}.perf.json')
|
|
1404
|
-
with open(perf_json_path, 'w') as f:
|
|
1405
|
-
json.dump(cfg, f, indent=2)
|
|
1406
|
-
|
|
1407
|
-
perf_md_path = os.path.join(args.outdir, f'{base}.perf.md')
|
|
1408
|
-
with open(perf_md_path, 'w') as f:
|
|
1409
|
-
f.write(render_perf_md(cfg, os.path.basename(coll_path)))
|
|
1410
|
-
|
|
1411
|
-
report_path = os.path.join(args.outdir, f'{base}.report.md')
|
|
1412
|
-
with open(report_path, 'w') as f:
|
|
1413
|
-
f.write(render_report(plan, cfg, manual_tasks, base))
|
|
1414
|
-
|
|
1415
|
-
if args.json_report:
|
|
1416
|
-
with open(os.path.join(args.outdir, f'{base}.ir.json'), 'w') as f:
|
|
1417
|
-
json.dump({'plan_name': plan['name'], 'variables': plan['variables'],
|
|
1418
|
-
'warnings': plan['warnings'], 'manual_tasks': manual_tasks,
|
|
1419
|
-
'thread_groups': [tg['load'] for tg in plan['thread_groups']]}, f, indent=2)
|
|
1420
|
-
|
|
1421
|
-
print(f'OK {args.input}')
|
|
1422
|
-
print(f' collection -> {coll_path}')
|
|
1423
|
-
print(f' perf -> {perf_md_path}')
|
|
1424
|
-
print(f' report -> {report_path}')
|
|
1425
|
-
print(f' thread_groups={len(plan["thread_groups"])} warnings={len(plan["warnings"])} manual_tasks={len(manual_tasks)}')
|
|
1426
|
-
|
|
1427
|
-
if args.fail_on_blockers and manual_tasks:
|
|
1428
|
-
print(f'FAIL {len(manual_tasks)} item(s) need manual work (--fail-on-blockers).', file=sys.stderr)
|
|
1429
|
-
sys.exit(3)
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
if __name__ == '__main__':
|
|
1433
|
-
main()
|