@postman/postman-plugin 0.2.2 → 0.4.0

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.
Files changed (53) hide show
  1. package/README.md +58 -10
  2. package/dist/hosts/antigravity.js +47 -0
  3. package/dist/hosts/copilot.js +60 -0
  4. package/dist/hosts/index.js +3 -1
  5. package/dist/hosts/opencode-config.js +147 -0
  6. package/dist/hosts/opencode.js +315 -34
  7. package/dist/hosts/shared.js +10 -3
  8. package/dist/source.js +19 -5
  9. package/dist/system.js +20 -1
  10. package/hooks/session-start-context.md +1 -1
  11. package/mcp.pi.json +2 -2
  12. package/package.json +1 -1
  13. package/skills/ai-readiness/SKILL.md +40 -44
  14. package/skills/api-discovery/SKILL.md +64 -129
  15. package/skills/api-discovery/reference/orbit.md +17 -5
  16. package/skills/api-documentation/SKILL.md +38 -25
  17. package/skills/api-engineer/SKILL.md +78 -23
  18. package/skills/api-mocking/SKILL.md +117 -135
  19. package/skills/api-monitoring/SKILL.md +52 -129
  20. package/skills/api-simulation/SKILL.md +138 -0
  21. package/skills/api-simulation/reference/sim_yaml_schema.md +50 -0
  22. package/skills/api-testing/SKILL.md +48 -95
  23. package/skills/bootstrap/SKILL.md +123 -210
  24. package/skills/bootstrap/reference/cli_installation.md +4 -3
  25. package/skills/ci-integration/SKILL.md +53 -112
  26. package/skills/collection-schema-v3/SKILL.md +43 -204
  27. package/skills/collection-schema-v3/reference/http.md +150 -0
  28. package/skills/datasets/SKILL.md +89 -314
  29. package/skills/datasets/reference/spreadsheets.md +25 -0
  30. package/skills/flows/SKILL.md +57 -206
  31. package/skills/flows/reference/flow_cli_flags.md +3 -2
  32. package/skills/performance-testing/SKILL.md +46 -62
  33. package/skills/performance-testing/references/jmeter-import/README.md +113 -0
  34. package/skills/performance-testing/references/jmeter-import/import-report.md +52 -0
  35. package/skills/performance-testing/references/jmeter-import/mapping-table.md +97 -0
  36. package/skills/performance-testing/references/jmeter-import/postman-perf-capabilities.md +56 -0
  37. package/skills/performance-testing/references/jmeter-import/script-translation.md +98 -0
  38. package/skills/performance-testing/references/jmeter-import/scripts/groovy_to_js.py +83 -0
  39. package/skills/performance-testing/references/jmeter-import/scripts/jmx_to_postman.py +1433 -0
  40. package/skills/postman-mcp-server/SKILL.md +53 -59
  41. package/skills/postman-mcp-server/references/docs.md +0 -1
  42. package/skills/postman-mcp-server/references/learn.md +10 -9
  43. package/skills/postman-mcp-server/references/mock.md +0 -1
  44. package/skills/postman-mcp-server/references/search.md +0 -1
  45. package/skills/postman-mcp-server/references/security.md +0 -1
  46. package/skills/postman-mcp-server/references/setup.md +0 -1
  47. package/skills/postman-mcp-server/references/sync.md +0 -1
  48. package/skills/postman-mcp-server/references/test.md +0 -1
  49. package/skills/variables/SKILL.md +63 -0
  50. package/skills/webhook-testing/SKILL.md +103 -0
  51. package/skills/webhook-testing/reference/forwarding.md +10 -0
  52. package/skills/webhook-testing/reference/outbound_webhooks.md +25 -0
  53. package/skills/webhook-testing/reference/webhook_scripts.md +163 -0
@@ -0,0 +1,1433 @@
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()