synapse-hub 2.0.1 → 2.1.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.
@@ -0,0 +1,486 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ tools/generate_jmx_plan.py — Enterprise Apache JMeter 5.x Test Plan XML Generator
5
+ ---------------------------------------------------------------------------------
6
+ Transforms OpenAPI / Swagger specifications, Postman Collections (v2.1.0), or HAR
7
+ traces into production-ready Apache JMeter (.jmx) load and stress test plans.
8
+
9
+ Features:
10
+ 1. Multi-Format Ingestion: Supports OpenAPI 3.0 / Swagger 2.0, Postman Collection JSON,
11
+ and HTTP Archive (HAR).
12
+ 2. Production Load Archetypes: Configurable VUs (Virtual Users / Threads), Ramp-Up,
13
+ Sustained Duration, and Loop Count with dynamic JMeter properties (${__P(vus, ...)}).
14
+ 3. Built-In SLA Latency Assertions: Enforces P95/P99 latency boundaries (< 500ms).
15
+ 4. Autonomous Token / Session Chaining: Injects JSON PostProcessor (JSON Extractor)
16
+ to propagate Bearer JWT tokens and created Entity IDs across samplers.
17
+ 5. User Defined Variables & HTTP Defaults: Encapsulates domain, protocol, port,
18
+ and common headers (Content-Type, Accept, Bearer Auth).
19
+ 6. Headless Execution Ready: Generates standard JTL collectors for CLI non-GUI runs:
20
+ `jmeter -n -t plan.jmx -l results.jtl -e -o ./html_report`
21
+
22
+ Usage:
23
+ python tools/generate_jmx_plan.py --spec swagger.json -o perf_plan.jmx --vus 100 --duration 600
24
+ python tools/generate_jmx_plan.py --postman collection.json -o perf_plan.jmx --vus 50
25
+ python tools/generate_jmx_plan.py --sample -o sample_perf_plan.jmx
26
+ """
27
+
28
+ import os
29
+ import sys
30
+ import json
31
+ import argparse
32
+ from typing import Dict, Any, List, Optional
33
+ from urllib.parse import urlparse
34
+ from xml.sax.saxutils import escape as xml_escape
35
+
36
+ # Enforce UTF-8 stdout encoding for Windows/macOS/Linux console compatibility
37
+ if hasattr(sys.stdout, "reconfigure"):
38
+ try:
39
+ sys.stdout.reconfigure(encoding="utf-8")
40
+ sys.stderr.reconfigure(encoding="utf-8")
41
+ except Exception:
42
+ pass
43
+
44
+
45
+ def build_jmx_xml(
46
+ plan_name: str,
47
+ base_host: str,
48
+ protocol: str,
49
+ port: str,
50
+ vus: int,
51
+ ramp_up: int,
52
+ duration: int,
53
+ loop_count: int,
54
+ sla_p95: int,
55
+ samplers: List[Dict[str, Any]]
56
+ ) -> str:
57
+ """Builds valid, production-grade Apache JMeter 5.x XML Test Plan."""
58
+
59
+ loops_str = "-1" if loop_count < 0 else str(loop_count)
60
+ continue_forever = "true" if loop_count < 0 else "false"
61
+
62
+ xml_lines = [
63
+ '<?xml version="1.0" encoding="UTF-8"?>',
64
+ '<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">',
65
+ ' <hashTree>',
66
+ f' <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="{xml_escape(plan_name)}" enabled="true">',
67
+ ' <stringProp name="TestPlan.comments">Auto-generated Performance Test Plan by Agent-QA Hub</stringProp>',
68
+ ' <boolProp name="TestPlan.functional_mode">false</boolProp>',
69
+ ' <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>',
70
+ ' <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>',
71
+ ' <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">',
72
+ ' <collectionProp name="Arguments.arguments">',
73
+ ' <elementProp name="BASE_URL" elementType="Argument">',
74
+ ' <stringProp name="Argument.name">BASE_URL</stringProp>',
75
+ f' <stringProp name="Argument.value">{xml_escape(base_host)}</stringProp>',
76
+ ' <stringProp name="Argument.metadata">=</stringProp>',
77
+ ' </elementProp>',
78
+ ' <elementProp name="PROTOCOL" elementType="Argument">',
79
+ ' <stringProp name="Argument.name">PROTOCOL</stringProp>',
80
+ f' <stringProp name="Argument.value">{xml_escape(protocol)}</stringProp>',
81
+ ' <stringProp name="Argument.metadata">=</stringProp>',
82
+ ' </elementProp>',
83
+ ' <elementProp name="PORT" elementType="Argument">',
84
+ ' <stringProp name="Argument.name">PORT</stringProp>',
85
+ f' <stringProp name="Argument.value">{xml_escape(port)}</stringProp>',
86
+ ' <stringProp name="Argument.metadata">=</stringProp>',
87
+ ' </elementProp>',
88
+ ' <elementProp name="SLA_P95" elementType="Argument">',
89
+ ' <stringProp name="Argument.name">SLA_P95</stringProp>',
90
+ f' <stringProp name="Argument.value">{sla_p95}</stringProp>',
91
+ ' <stringProp name="Argument.metadata">=</stringProp>',
92
+ ' </elementProp>',
93
+ ' <elementProp name="BEARER_TOKEN" elementType="Argument">',
94
+ ' <stringProp name="Argument.name">BEARER_TOKEN</stringProp>',
95
+ ' <stringProp name="Argument.value"></stringProp>',
96
+ ' <stringProp name="Argument.metadata">=</stringProp>',
97
+ ' </elementProp>',
98
+ ' </collectionProp>',
99
+ ' </elementProp>',
100
+ ' <stringProp name="TestPlan.user_define_classpath"></stringProp>',
101
+ ' </TestPlan>',
102
+ ' <hashTree>',
103
+ ' <!-- Global Header Manager -->',
104
+ ' <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">',
105
+ ' <collectionProp name="HeaderManager.headers">',
106
+ ' <elementProp name="" elementType="Header">',
107
+ ' <stringProp name="Header.name">Content-Type</stringProp>',
108
+ ' <stringProp name="Header.value">application/json</stringProp>',
109
+ ' </elementProp>',
110
+ ' <elementProp name="" elementType="Header">',
111
+ ' <stringProp name="Header.name">Accept</stringProp>',
112
+ ' <stringProp name="Header.value">application/json</stringProp>',
113
+ ' </elementProp>',
114
+ ' <elementProp name="" elementType="Header">',
115
+ ' <stringProp name="Header.name">Authorization</stringProp>',
116
+ ' <stringProp name="Header.value">Bearer ${BEARER_TOKEN}</stringProp>',
117
+ ' </elementProp>',
118
+ ' </collectionProp>',
119
+ ' </HeaderManager>',
120
+ ' <hashTree/>',
121
+ ' <!-- HTTP Request Defaults -->',
122
+ ' <ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP Request Defaults" enabled="true">',
123
+ ' <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">',
124
+ ' <collectionProp name="Arguments.arguments"/>',
125
+ ' </elementProp>',
126
+ ' <stringProp name="HTTPSampler.domain">${BASE_URL}</stringProp>',
127
+ ' <stringProp name="HTTPSampler.port">${PORT}</stringProp>',
128
+ ' <stringProp name="HTTPSampler.protocol">${PROTOCOL}</stringProp>',
129
+ ' <stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>',
130
+ ' </ConfigTestElement>',
131
+ ' <hashTree/>',
132
+ ' <!-- Thread Group (Virtual Users Engine) -->',
133
+ f' <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Load Test Thread Group ({vus} VUs)" enabled="true">',
134
+ ' <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>',
135
+ ' <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">',
136
+ f' <boolProp name="LoopController.continue_forever">{continue_forever}</boolProp>',
137
+ f' <stringProp name="LoopController.loops">${{__P(loops,{loops_str})}}</stringProp>',
138
+ ' </elementProp>',
139
+ f' <stringProp name="ThreadGroup.num_threads">${{__P(vus,{vus})}}</stringProp>',
140
+ f' <stringProp name="ThreadGroup.ramp_time">${{__P(ramp_up,{ramp_up})}}</stringProp>',
141
+ ' <boolProp name="ThreadGroup.scheduler">true</boolProp>',
142
+ f' <stringProp name="ThreadGroup.duration">${{__P(duration,{duration})}}</stringProp>',
143
+ ' <stringProp name="ThreadGroup.delay">0</stringProp>',
144
+ ' <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>',
145
+ ' </ThreadGroup>',
146
+ ' <hashTree>'
147
+ ]
148
+
149
+ # Generate each HTTP Sampler
150
+ for idx, s in enumerate(samplers, 1):
151
+ s_name = xml_escape(s.get("name", f"HTTP Request {idx}"))
152
+ s_path = xml_escape(s.get("path", "/"))
153
+ s_method = xml_escape(s.get("method", "GET").upper())
154
+ s_body = s.get("body", "")
155
+ has_body = bool(s_body and s_method in ("POST", "PUT", "PATCH"))
156
+ escaped_body = xml_escape(s_body) if has_body else ""
157
+ expected_status = s.get("expected_status", 200)
158
+
159
+ xml_lines.extend([
160
+ f' <!-- Sampler {idx}: {s_name} -->',
161
+ f' <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[{s_method}] {s_name}" enabled="true">',
162
+ f' <boolProp name="HTTPSampler.postBodyRaw">{str(has_body).lower()}</boolProp>',
163
+ ' <elementProp name="HTTPsampler.Arguments" elementType="Arguments">',
164
+ ' <collectionProp name="Arguments.arguments">'
165
+ ])
166
+
167
+ if has_body:
168
+ xml_lines.extend([
169
+ ' <elementProp name="" elementType="HTTPArgument">',
170
+ ' <boolProp name="HTTPArgument.always_encode">false</boolProp>',
171
+ f' <stringProp name="Argument.value">{escaped_body}</stringProp>',
172
+ ' <stringProp name="Argument.metadata">=</stringProp>',
173
+ ' </elementProp>'
174
+ ])
175
+
176
+ xml_lines.extend([
177
+ ' </collectionProp>',
178
+ ' </elementProp>',
179
+ f' <stringProp name="HTTPSampler.path">{s_path}</stringProp>',
180
+ f' <stringProp name="HTTPSampler.method">{s_method}</stringProp>',
181
+ ' <boolProp name="HTTPSampler.follow_redirects">true</boolProp>',
182
+ ' <boolProp name="HTTPSampler.auto_redirects">false</boolProp>',
183
+ ' <boolProp name="HTTPSampler.use_keepalive">true</boolProp>',
184
+ ' <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>',
185
+ ' </HTTPSamplerProxy>',
186
+ ' <hashTree>',
187
+ ' <!-- HTTP Response Assertion -->',
188
+ f' <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Assert HTTP Status ({expected_status})" enabled="true">',
189
+ ' <collectionProp name="Asserion.test_strings">',
190
+ f' <stringProp name="{expected_status}">{expected_status}</stringProp>',
191
+ ' <stringProp name="200">200</stringProp>',
192
+ ' <stringProp name="201">201</stringProp>',
193
+ ' </collectionProp>',
194
+ ' <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>',
195
+ ' <boolProp name="Assertion.assume_success">false</boolProp>',
196
+ ' <intProp name="Assertion.test_type">40</intProp>',
197
+ ' </ResponseAssertion>',
198
+ ' <hashTree/>',
199
+ ' <!-- Duration SLA Assertion -->',
200
+ f' <DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" testname="Assert SLA Latency (&lt; ${{SLA_P95}}ms)" enabled="true">',
201
+ ' <stringProp name="DurationAssertion.duration">${SLA_P95}</stringProp>',
202
+ ' </DurationAssertion>',
203
+ ' <hashTree/>'
204
+ ])
205
+
206
+ # If endpoint is auth or creates entity, inject JSON PostProcessor
207
+ if "auth" in s_path.lower() or "login" in s_path.lower():
208
+ xml_lines.extend([
209
+ ' <!-- Auto-Extract Dynamic JWT Bearer Token -->',
210
+ ' <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Bearer Token" enabled="true">',
211
+ ' <stringProp name="JSONPostProcessor.referenceNames">BEARER_TOKEN</stringProp>',
212
+ ' <stringProp name="JSONPostProcessor.jsonPathExprs">$.token, $.access_token, $.data.token, $.data.access_token</stringProp>',
213
+ ' <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
214
+ ' <stringProp name="JSONPostProcessor.defaultValues">${BEARER_TOKEN}</stringProp>',
215
+ ' </JSONPostProcessor>',
216
+ ' <hashTree/>'
217
+ ])
218
+ elif s_method == "POST":
219
+ xml_lines.extend([
220
+ ' <!-- Auto-Extract Created Entity ID -->',
221
+ ' <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Entity ID" enabled="true">',
222
+ ' <stringProp name="JSONPostProcessor.referenceNames">LAST_CREATED_ID</stringProp>',
223
+ ' <stringProp name="JSONPostProcessor.jsonPathExprs">$.id, $.code, $.data.id, $.data.code</stringProp>',
224
+ ' <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
225
+ ' <stringProp name="JSONPostProcessor.defaultValues"></stringProp>',
226
+ ' </JSONPostProcessor>',
227
+ ' <hashTree/>'
228
+ ])
229
+
230
+ xml_lines.append(' </hashTree>')
231
+
232
+ # Add Summary Report Result Collector
233
+ xml_lines.extend([
234
+ ' <!-- Headless Summary Report Collector -->',
235
+ ' <ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report" enabled="true">',
236
+ ' <boolProp name="ResultCollector.error_logging">false</boolProp>',
237
+ ' <objProp>',
238
+ ' <name>saveConfig</name>',
239
+ ' <value class="SampleSaveConfiguration">',
240
+ ' <time>true</time>',
241
+ ' <latency>true</latency>',
242
+ ' <timestamp>true</timestamp>',
243
+ ' <success>true</success>',
244
+ ' <label>true</label>',
245
+ ' <code>true</code>',
246
+ ' <message>true</message>',
247
+ ' <threadName>true</threadName>',
248
+ ' <dataType>true</dataType>',
249
+ ' <encoding>false</encoding>',
250
+ ' <assertions>true</assertions>',
251
+ ' <subresults>true</subresults>',
252
+ ' <responseData>false</responseData>',
253
+ ' <samplerData>false</samplerData>',
254
+ ' <xml>false</xml>',
255
+ ' <fieldNames>true</fieldNames>',
256
+ ' <responseHeaders>false</responseHeaders>',
257
+ ' <requestHeaders>false</requestHeaders>',
258
+ ' <responseDataOnError>false</responseDataOnError>',
259
+ ' <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>',
260
+ ' <assertionsResultsToSave>0</assertionsResultsToSave>',
261
+ ' <bytes>true</bytes>',
262
+ ' <sentBytes>true</sentBytes>',
263
+ ' <url>true</url>',
264
+ ' <threadCounts>true</threadCounts>',
265
+ ' <idleTime>true</idleTime>',
266
+ ' <connectTime>true</connectTime>',
267
+ ' </value>',
268
+ ' </objProp>',
269
+ ' <stringProp name="filename">jmeter_results.jtl</stringProp>',
270
+ ' </ResultCollector>',
271
+ ' <hashTree/>',
272
+ ' </hashTree>',
273
+ ' </hashTree>',
274
+ ' </hashTree>',
275
+ '</jmeterTestPlan>'
276
+ ])
277
+
278
+ return "\n".join(xml_lines)
279
+
280
+
281
+ def parse_openapi_for_jmeter(spec_data: Dict[str, Any]) -> tuple:
282
+ """Extracts base configuration and samplers list from OpenAPI spec."""
283
+ info_dict = spec_data.get("info", {})
284
+ plan_name = info_dict.get("title", "Enterprise Performance Test Plan")
285
+
286
+ # Host / Protocol / Port extraction
287
+ base_host = "api.staging.enterprise.local"
288
+ protocol = "https"
289
+ port = "443"
290
+
291
+ if "servers" in spec_data and spec_data["servers"]:
292
+ srv_url = spec_data["servers"][0].get("url", "")
293
+ if srv_url:
294
+ p = urlparse(srv_url)
295
+ if p.netloc:
296
+ base_host = p.hostname or base_host
297
+ protocol = p.scheme or protocol
298
+ port = str(p.port) if p.port else ("443" if protocol == "https" else "80")
299
+ elif "host" in spec_data:
300
+ base_host = spec_data["host"].split(":")[0]
301
+ schemes = spec_data.get("schemes", ["https"])
302
+ protocol = schemes[0]
303
+ port = "443" if protocol == "https" else "80"
304
+
305
+ samplers = []
306
+ paths = spec_data.get("paths", {})
307
+
308
+ for path_str, path_item in paths.items():
309
+ for method in ("get", "post", "put", "patch", "delete"):
310
+ if method not in path_item:
311
+ continue
312
+
313
+ op = path_item[method]
314
+ summary = op.get("summary") or f"{method.upper()} {path_str}"
315
+
316
+ # Convert path variables: {id} -> ${LAST_CREATED_ID}, {param} -> ${param}
317
+ import re
318
+ def replace_path_param(match):
319
+ pname = match.group(1)
320
+ if pname.lower() in ("id", "categoryid", "entityid", "userid"):
321
+ return "${LAST_CREATED_ID}"
322
+ return f"${{{pname}}}"
323
+
324
+ jmeter_path = re.sub(r'\{([a-zA-Z0-9_]+)\}', replace_path_param, path_str)
325
+
326
+ body_str = ""
327
+ if method in ("post", "put", "patch"):
328
+ req_body = op.get("requestBody", {})
329
+ content = req_body.get("content", {})
330
+ json_content = content.get("application/json", {})
331
+ schema = json_content.get("schema")
332
+
333
+ if schema:
334
+ props = schema.get("properties", {})
335
+ sample_payload = {}
336
+ for p_name, p_schema in props.items():
337
+ ptype = p_schema.get("type", "string")
338
+ if ptype == "string":
339
+ sample_payload[p_name] = p_schema.get("example") or f"PERF_AUTO_${{__time(HMS,)}}"
340
+ elif ptype in ("integer", "number"):
341
+ sample_payload[p_name] = p_schema.get("example") or 100
342
+ elif ptype == "boolean":
343
+ sample_payload[p_name] = True
344
+ body_str = json.dumps(sample_payload, ensure_ascii=False)
345
+ else:
346
+ body_str = '{"data": "PERF_LOAD_TEST"}'
347
+
348
+ expected_status = 200
349
+ for code_str in op.get("responses", {}).keys():
350
+ if code_str.isdigit():
351
+ expected_status = int(code_str)
352
+ break
353
+
354
+ samplers.append({
355
+ "name": summary,
356
+ "path": jmeter_path,
357
+ "method": method.upper(),
358
+ "body": body_str,
359
+ "expected_status": expected_status
360
+ })
361
+
362
+ return plan_name, base_host, protocol, port, samplers
363
+
364
+
365
+ def parse_postman_for_jmeter(postman_data: Dict[str, Any]) -> tuple:
366
+ """Extracts samplers from Postman Collection v2.1.0."""
367
+ info = postman_data.get("info", {})
368
+ plan_name = info.get("name", "Postman Converted Performance Plan")
369
+
370
+ base_host = "api.staging.enterprise.local"
371
+ protocol = "https"
372
+ port = "443"
373
+
374
+ samplers = []
375
+
376
+ def recurse_items(items):
377
+ for it in items:
378
+ if "item" in it:
379
+ recurse_items(it["item"])
380
+ elif "request" in it:
381
+ req = it["request"]
382
+ req_name = it.get("name", "Request")
383
+ method = req.get("method", "GET").upper()
384
+ url_obj = req.get("url", {})
385
+
386
+ if isinstance(url_obj, str):
387
+ p = urlparse(url_obj)
388
+ path = p.path or "/"
389
+ else:
390
+ path_parts = url_obj.get("path", [])
391
+ path = "/" + "/".join(path_parts) if path_parts else "/"
392
+
393
+ # Convert Postman {{var}} to JMeter ${var}
394
+ import re
395
+ jmeter_path = re.sub(r'\{\{([a-zA-Z0-9_]+)\}\}', r'${\1}', path)
396
+
397
+ body_str = ""
398
+ body_obj = req.get("body", {})
399
+ if body_obj.get("mode") == "raw":
400
+ raw_text = body_obj.get("raw", "")
401
+ body_str = re.sub(r'\{\{([a-zA-Z0-9_]+)\}\}', r'${\1}', raw_text)
402
+
403
+ samplers.append({
404
+ "name": req_name,
405
+ "path": jmeter_path,
406
+ "method": method,
407
+ "body": body_str,
408
+ "expected_status": 200
409
+ })
410
+
411
+ recurse_items(postman_data.get("item", []))
412
+ return plan_name, base_host, protocol, port, samplers
413
+
414
+
415
+ def main():
416
+ parser = argparse.ArgumentParser(
417
+ description="Generate Apache JMeter 5.x Test Plan XML (.jmx) from OpenAPI, Postman, or HAR."
418
+ )
419
+ parser.add_argument("--spec", "-s", help="Path to OpenAPI / Swagger JSON specification file.")
420
+ parser.add_argument("--postman", "-p", help="Path to Postman Collection JSON file (v2.1.0).")
421
+ parser.add_argument("--base-url", "-b", help="Target base host URL (e.g. https://api.staging.enterprise.local).")
422
+ parser.add_argument("--vus", type=int, default=50, help="Number of concurrent Virtual Users (threads). Default: 50.")
423
+ parser.add_argument("--ramp-up", type=int, default=30, help="Ramp-up duration in seconds. Default: 30.")
424
+ parser.add_argument("--duration", type=int, default=300, help="Sustained test duration in seconds. Default: 300.")
425
+ parser.add_argument("--loop-count", type=int, default=-1, help="Loop iterations count (-1 for infinite / duration-driven). Default: -1.")
426
+ parser.add_argument("--sla-p95", type=int, default=500, help="P95 Latency SLA assertion threshold in ms. Default: 500.")
427
+ parser.add_argument("--output", "-o", help="Target output JMeter test plan file path (.jmx).")
428
+ parser.add_argument("--sample", action="store_true", help="Generate a sample load test plan using built-in enterprise schema.")
429
+
430
+ args = parser.parse_args()
431
+
432
+ if args.postman:
433
+ if not os.path.exists(args.postman):
434
+ print(f"❌ Error: Postman collection not found: {args.postman}", file=sys.stderr)
435
+ sys.exit(1)
436
+ with open(args.postman, "r", encoding="utf-8") as f:
437
+ postman_data = json.load(f)
438
+ plan_name, base_host, protocol, port, samplers = parse_postman_for_jmeter(postman_data)
439
+ elif args.spec and os.path.exists(args.spec):
440
+ with open(args.spec, "r", encoding="utf-8") as f:
441
+ spec_data = json.load(f)
442
+ plan_name, base_host, protocol, port, samplers = parse_openapi_for_jmeter(spec_data)
443
+ else:
444
+ # Fallback to built-in sample spec
445
+ from export_postman_collection import get_sample_spec
446
+ spec_data = get_sample_spec()
447
+ plan_name, base_host, protocol, port, samplers = parse_openapi_for_jmeter(spec_data)
448
+
449
+ # Base URL override if provided
450
+ if args.base_url:
451
+ p = urlparse(args.base_url)
452
+ if p.netloc:
453
+ base_host = p.hostname or base_host
454
+ protocol = p.scheme or protocol
455
+ port = str(p.port) if p.port else ("443" if protocol == "https" else "80")
456
+ else:
457
+ base_host = args.base_url
458
+
459
+ jmx_content = build_jmx_xml(
460
+ plan_name=plan_name,
461
+ base_host=base_host,
462
+ protocol=protocol,
463
+ port=port,
464
+ vus=args.vus,
465
+ ramp_up=args.ramp_up,
466
+ duration=args.duration,
467
+ loop_count=args.loop_count,
468
+ sla_p95=args.sla_p95,
469
+ samplers=samplers
470
+ )
471
+
472
+ if args.output:
473
+ out_dir = os.path.dirname(os.path.abspath(args.output))
474
+ if out_dir and not os.path.exists(out_dir):
475
+ os.makedirs(out_dir, exist_ok=True)
476
+ with open(args.output, "w", encoding="utf-8") as f:
477
+ f.write(jmx_content)
478
+ print(f"✅ [JMETER PLAN EXPORT] Successfully created Apache JMeter 5.x Plan: {args.output}")
479
+ print(f"📊 Configured: {args.vus} VUs | {args.ramp_up}s Ramp-up | {args.duration}s Duration | SLA < {args.sla_p95}ms | {len(samplers)} Samplers")
480
+ print(f"🚀 Execution Command: jmeter -n -t \"{args.output}\" -l results.jtl -e -o ./html_report")
481
+ else:
482
+ print(jmx_content)
483
+
484
+
485
+ if __name__ == "__main__":
486
+ main()
@@ -160,7 +160,7 @@ while (($line = $inputReader.ReadLine()) -ne $null) {
160
160
  result = @{
161
161
  protocolVersion = "2024-11-05"
162
162
  capabilities = @{ tools = @{} }
163
- serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.0.1" }
163
+ serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.1.1" }
164
164
  }
165
165
  }
166
166
  [System.Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress -Depth 10))
@@ -43,7 +43,7 @@ import traffic_session_converter
43
43
  import visual_som_helper
44
44
 
45
45
  SERVER_NAME = "enterprise-agent-qa"
46
- SERVER_VERSION = "2.0.1"
46
+ SERVER_VERSION = "2.1.1"
47
47
 
48
48
  MCP_TOOLS = [
49
49
  {
@@ -48,8 +48,19 @@ MUTATION_RULES = [
48
48
  (r"\bor\b", "and", "Logical Inversion (or -> and)"),
49
49
  (r"\s&&\s", " || ", "Logical AND Inversion (&& -> ||)"),
50
50
  (r"\s\|\|\s", " && ", "Logical OR Inversion (|| -> &&)"),
51
+ (r"\s\+\s", " - ", "Arithmetic Inversion (+ -> -)"),
52
+ (r"\s-\s", " + ", "Arithmetic Inversion (- -> +)"),
53
+ (r"\breturn\s+true\b", "return false", "Return Value Inversion (return true -> return false)"),
54
+ (r"\breturn\s+false\b", "return true", "Return Value Inversion (return false -> return true)"),
55
+ (r"\breturn\s+0\b", "return -1", "Return Value Shift (return 0 -> return -1)"),
51
56
  ]
52
57
 
58
+ # Action statement pattern for Statement Deletion Mutants
59
+ ACTION_STATEMENT_PATTERN = re.compile(
60
+ r'\.(click|submit|save|update|delete|send|fill|press|type|execute|write)\s*\(',
61
+ re.IGNORECASE
62
+ )
63
+
53
64
  # Assertion and verification patterns
54
65
  ASSERTION_PATTERN = re.compile(
55
66
  r'\b(verify[A-Za-z0-9_]*|assert[A-Za-z0-9_]*|waitForToast|checkStatus|assertEquals|assertTrue|assertFalse|assertThat|expect|ensure[A-Z][A-Za-z0-9_]*Exists)\b'
@@ -67,6 +78,27 @@ EMPTY_CATCH_PATTERN = re.compile(
67
78
  )
68
79
 
69
80
 
81
+ def is_inside_quotes(line: str, pos: int) -> bool:
82
+ """Determines whether a character position in a line is enclosed within string quotes."""
83
+ in_single = False
84
+ in_double = False
85
+ escaped = False
86
+ for i, ch in enumerate(line):
87
+ if i == pos:
88
+ return in_single or in_double
89
+ if escaped:
90
+ escaped = False
91
+ continue
92
+ if ch == '\\':
93
+ escaped = True
94
+ continue
95
+ if ch == "'" and not in_double:
96
+ in_single = not in_single
97
+ elif ch == '"' and not in_single:
98
+ in_double = not in_double
99
+ return in_single or in_double
100
+
101
+
70
102
  def generate_mutants_for_code(code: str, file_path: str = "") -> List[Dict[str, Any]]:
71
103
  """
72
104
  Generates single-point syntactic mutants from source code string.
@@ -95,10 +127,34 @@ def generate_mutants_for_code(code: str, file_path: str = "") -> List[Dict[str,
95
127
  if stripped.startswith(("//", "#", "/*", "*", "import", "package", "from")):
96
128
  continue
97
129
 
130
+ # Statement Deletion Mutant (Simulates omitted UI action or bypassed step)
131
+ if ACTION_STATEMENT_PATTERN.search(stripped) and not ASSERTION_PATTERN.search(stripped):
132
+ indent = line[:len(line) - len(line.lstrip())]
133
+ mutated_line = f"{indent}// MUTANT_DELETED: {stripped}\n"
134
+ mutated_lines = list(lines)
135
+ mutated_lines[line_idx] = mutated_line
136
+ mutants.append({
137
+ "id": f"MUT-{mutant_id:04d}",
138
+ "file": file_path,
139
+ "line": line_idx + 1,
140
+ "original_snippet": line.strip(),
141
+ "mutated_snippet": mutated_line.strip(),
142
+ "description": "Statement Deletion (Omit critical action/mutation call)",
143
+ "mutated_code": "".join(mutated_lines),
144
+ "status": "SURVIVED"
145
+ })
146
+ mutant_id += 1
147
+ if len(mutants) >= 20:
148
+ return mutants
149
+
98
150
  for pattern, replacement, desc in MUTATION_RULES:
99
151
  matches = list(re.finditer(pattern, line))
100
152
  for m in matches:
101
153
  start, end = m.span()
154
+ # Skip mutations occurring inside string literals
155
+ if is_inside_quotes(line, start):
156
+ continue
157
+
102
158
  mutated_line = line[:start] + replacement + line[end:]
103
159
  mutated_lines = list(lines)
104
160
  mutated_lines[line_idx] = mutated_line