synapse-hub 2.1.1 → 2.2.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 (58) hide show
  1. package/.agents/agents/orchestrator.md +7 -0
  2. package/.agents/rules/lessons-learned.md +1 -0
  3. package/.agents/skills/agent-orchestration-multi-agent-optimize/SKILL.md +1 -1
  4. package/.agents/skills/antigravity-test-sentinel/SKILL.md +1 -2
  5. package/.agents/skills/api-network-mocking-interception/SKILL.md +1 -1
  6. package/.agents/skills/api-spec-to-e2e-matrix/SKILL.md +11 -4
  7. package/.agents/skills/architecture-decision-records/SKILL.md +1 -1
  8. package/.agents/skills/auth-session-and-cookie-manager/SKILL.md +1 -1
  9. package/.agents/skills/backend-load-and-stress-patterns/SKILL.md +32 -11
  10. package/.agents/skills/bats-testing-patterns/SKILL.md +1 -1
  11. package/.agents/skills/clean-pom-crafter/SKILL.md +1 -4
  12. package/.agents/skills/code-refactoring-refactor-clean/SKILL.md +1 -1
  13. package/.agents/skills/db-state-verifier-and-seeder/SKILL.md +1 -1
  14. package/.agents/skills/e2e-testing-patterns/SKILL.md +1 -1
  15. package/.agents/skills/failure-autopsy-and-healing/SKILL.md +1 -3
  16. package/.agents/skills/file-and-report-validator/SKILL.md +1 -1
  17. package/.agents/skills/flaky-test-sentinel-and-quarantine/SKILL.md +1 -1
  18. package/.agents/skills/github-actions-templates/SKILL.md +1 -1
  19. package/.agents/skills/graphify-impact-analyzer/SKILL.md +1 -3
  20. package/.agents/skills/katalon-cli-runner/SKILL.md +1 -1
  21. package/.agents/skills/lam-script-generator/SKILL.md +1 -2
  22. package/.agents/skills/playwright-live-interaction/SKILL.md +1 -4
  23. package/.agents/skills/security-dast-sentinel/SKILL.md +1 -1
  24. package/.agents/skills/self-healing-locator/SKILL.md +1 -2
  25. package/.agents/skills/skillspector-security-guard/SKILL.md +115 -0
  26. package/.agents/skills/synthetic-test-data-generator/SKILL.md +1 -1
  27. package/.agents/skills/token-and-context-optimizer/SKILL.md +1 -1
  28. package/.agents/skills/visual-regression-sentinel/SKILL.md +1 -1
  29. package/.agents/skills/wcag-audit-patterns/SKILL.md +1 -1
  30. package/.agents/skills/web-vitals-and-performance-profiler/SKILL.md +1 -1
  31. package/AGENTS.md +36 -0
  32. package/README.md +140 -103
  33. package/package.json +1 -1
  34. package/skills_manifest.json +42 -29
  35. package/templates/PROJECT_AI_GUIDE_TEMPLATE.md +15 -1
  36. package/templates/ai_bridges/CLAUDE.md +31 -0
  37. package/templates/ai_bridges/copilot-instructions.md +25 -0
  38. package/templates/ai_bridges/cursor_synapse_qa.mdc +26 -0
  39. package/templates/ai_bridges/windsurfrules +25 -0
  40. package/templates/ci/github-actions-synapse.yml +28 -0
  41. package/templates/ci/gitlab-ci-synapse.yml +16 -0
  42. package/tools/TOOLS_RUNBOOK.md +203 -12
  43. package/tools/browser_helpers.js +2 -2
  44. package/tools/bump_version.py +8 -0
  45. package/tools/cli.py +74 -2
  46. package/tools/export_postman_collection.py +535 -218
  47. package/tools/generate_jmx_plan.py +672 -102
  48. package/tools/git_pre_commit_guard.py +21 -0
  49. package/tools/jtl_analyzer.py +474 -0
  50. package/tools/mcp_excel_server.ps1 +1 -1
  51. package/tools/mcp_server.py +1 -1
  52. package/tools/newman_analyzer.py +385 -0
  53. package/tools/setup_project.py +203 -18
  54. package/tools/skillspector.py +1141 -0
  55. package/tools/telemetry_correlator.py +351 -0
  56. package/tools/templates/jmeter-distributed/README.md +54 -0
  57. package/tools/templates/jmeter-distributed/docker-compose.yml +83 -0
  58. package/tools/update_sentinel.py +313 -0
@@ -1,35 +1,50 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- tools/generate_jmx_plan.py — Enterprise Apache JMeter 5.x Test Plan XML Generator
5
- ---------------------------------------------------------------------------------
4
+ tools/generate_jmx_plan.py — Enterprise Apache JMeter 5.x Test Plan XML Generator (JMeter 360° Pinnacle)
5
+ ---------------------------------------------------------------------------------------------------------
6
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`
7
+ traces into production-ready Apache JMeter (.jmx) load, stress, spike, and soak test plans.
8
+
9
+ Comprehensive 10/10 Pinnacle Performance Features:
10
+ 1. Multi-Format Ingestion: OpenAPI 3.0 / Swagger 2.0, Postman Collection JSON, and HAR traces.
11
+ 2. Browser State Simulation: Injects HTTP Cookie Manager & HTTP Cache Manager by default.
12
+ 3. Dynamic Parameterization: Injects CSV Data Set Config for multi-user test data.
13
+ 4. Advanced Logic Controllers:
14
+ - Transaction Controllers (Parent aggregations for end-to-end SLA tracking)
15
+ - Traffic Shaping & Weighted Routing via ThroughputController (Pareto 70/20/10 traffic mix)
16
+ - Uniform Random Timers for realistic human think-time.
17
+ 5. Deep Data Correlation Engine:
18
+ - RegexExtractor for HTML hidden CSRF / Anti-Forgery tokens
19
+ - JSONPostProcessor with Match Number 0 (Random dynamic item choice avoiding duplicate lockups)
20
+ - Chaining Bearer JWT token & entity IDs.
21
+ 6. Workload Archetypes: Linear, Step-Up (Break-point), Spike, and Soak profiles.
22
+ 7. Enterprise Security: JSR223 Groovy HMAC-SHA256 payload signer & correlation IDs.
23
+ 8. Full-Stack Observability & Telemetry:
24
+ - InfluxDB Backend Listener for live Grafana APM dashboard streaming
25
+ - PerfMon Server Agent Collector (CPU, RAM, Disks, Network I/O).
26
+ 9. Multi-Protocol Coverage (100% Comprehensive):
27
+ - REST / HTTP(S) Samplers
28
+ - JDBC Database Connection Pool & Query Samplers
29
+ - WebSocket Real-Time Samplers (Connect, Send, Receive, Close)
30
+ - gRPC Microservices Samplers (Protobuf & Unary RPC)
31
+ - Kafka / JMS Event-Driven Producer Samplers.
21
32
 
22
33
  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
34
+ python tools/generate_jmx_plan.py --spec swagger.json -o perf_plan.jmx --weighted-mix "70,20,10"
35
+ python tools/generate_jmx_plan.py --postman col.json -o perf_plan.jmx --csv-data users.csv --deep-correlation
36
+ python tools/generate_jmx_plan.py --ws-sample -o websocket_plan.jmx
37
+ python tools/generate_jmx_plan.py --grpc-sample -o grpc_plan.jmx
38
+ python tools/generate_jmx_plan.py --kafka-sample -o kafka_plan.jmx
39
+ python tools/generate_jmx_plan.py --jdbc-sample -o jdbc_plan.jmx
26
40
  """
27
41
 
28
42
  import os
29
43
  import sys
30
44
  import json
45
+ import re
31
46
  import argparse
32
- from typing import Dict, Any, List, Optional
47
+ from typing import Dict, Any, List, Optional, Tuple
33
48
  from urllib.parse import urlparse
34
49
  from xml.sax.saxutils import escape as xml_escape
35
50
 
@@ -52,7 +67,24 @@ def build_jmx_xml(
52
67
  duration: int,
53
68
  loop_count: int,
54
69
  sla_p95: int,
55
- samplers: List[Dict[str, Any]]
70
+ samplers: List[Dict[str, Any]],
71
+ enable_cookies: bool = True,
72
+ enable_cache: bool = True,
73
+ csv_file: Optional[str] = None,
74
+ csv_variables: str = "username,password",
75
+ group_transactions: bool = False,
76
+ weighted_mix: Optional[str] = None,
77
+ deep_correlation: bool = True,
78
+ workload_profile: str = "linear",
79
+ jsr223_hmac: bool = False,
80
+ influxdb_url: Optional[str] = None,
81
+ perfmon_server: Optional[str] = None,
82
+ think_time_min: int = 0,
83
+ think_time_max: int = 0,
84
+ jdbc_config: Optional[Dict[str, Any]] = None,
85
+ ws_config: Optional[Dict[str, Any]] = None,
86
+ grpc_config: Optional[Dict[str, Any]] = None,
87
+ kafka_config: Optional[Dict[str, Any]] = None
56
88
  ) -> str:
57
89
  """Builds valid, production-grade Apache JMeter 5.x XML Test Plan."""
58
90
 
@@ -64,7 +96,7 @@ def build_jmx_xml(
64
96
  '<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">',
65
97
  ' <hashTree>',
66
98
  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>',
99
+ ' <stringProp name="TestPlan.comments">Enterprise Performance Test Plan auto-generated by Synapse-Hub</stringProp>',
68
100
  ' <boolProp name="TestPlan.functional_mode">false</boolProp>',
69
101
  ' <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>',
70
102
  ' <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>',
@@ -95,11 +127,54 @@ def build_jmx_xml(
95
127
  ' <stringProp name="Argument.value"></stringProp>',
96
128
  ' <stringProp name="Argument.metadata">=</stringProp>',
97
129
  ' </elementProp>',
130
+ ' <elementProp name="CSRF_TOKEN" elementType="Argument">',
131
+ ' <stringProp name="Argument.name">CSRF_TOKEN</stringProp>',
132
+ ' <stringProp name="Argument.value"></stringProp>',
133
+ ' <stringProp name="Argument.metadata">=</stringProp>',
134
+ ' </elementProp>',
135
+ ' <elementProp name="RANDOM_ITEM_ID" elementType="Argument">',
136
+ ' <stringProp name="Argument.name">RANDOM_ITEM_ID</stringProp>',
137
+ ' <stringProp name="Argument.value">1</stringProp>',
138
+ ' <stringProp name="Argument.metadata">=</stringProp>',
139
+ ' </elementProp>',
140
+ ' <elementProp name="HMAC_SECRET" elementType="Argument">',
141
+ ' <stringProp name="Argument.name">HMAC_SECRET</stringProp>',
142
+ ' <stringProp name="Argument.value">SynapseEnterpriseVault#2026</stringProp>',
143
+ ' <stringProp name="Argument.metadata">=</stringProp>',
144
+ ' </elementProp>',
98
145
  ' </collectionProp>',
99
146
  ' </elementProp>',
100
147
  ' <stringProp name="TestPlan.user_define_classpath"></stringProp>',
101
148
  ' </TestPlan>',
102
- ' <hashTree>',
149
+ ' <hashTree>'
150
+ ]
151
+
152
+ # Global HTTP Cookie Manager
153
+ if enable_cookies:
154
+ xml_lines.extend([
155
+ ' <!-- Global Cookie Manager (Browser Session Emulation) -->',
156
+ ' <CookieManager guiclass="CookiePanel" testclass="CookieManager" testname="HTTP Cookie Manager" enabled="true">',
157
+ ' <collectionProp name="CookieManager.cookies"/>',
158
+ ' <boolProp name="CookieManager.clearEachIteration">false</boolProp>',
159
+ ' <boolProp name="CookieManager.controlledByThreadGroup">false</boolProp>',
160
+ ' </CookieManager>',
161
+ ' <hashTree/>'
162
+ ])
163
+
164
+ # Global HTTP Cache Manager
165
+ if enable_cache:
166
+ xml_lines.extend([
167
+ ' <!-- Global Cache Manager (ETag & Cache-Control Emulation) -->',
168
+ ' <CacheManager guiclass="CacheManagerGui" testclass="CacheManager" testname="HTTP Cache Manager" enabled="true">',
169
+ ' <boolProp name="clearEachIteration">false</boolProp>',
170
+ ' <boolProp name="useCacheControl">true</boolProp>',
171
+ ' <intProp name="maxSize">5000</intProp>',
172
+ ' </CacheManager>',
173
+ ' <hashTree/>'
174
+ ])
175
+
176
+ # Global Header Manager
177
+ xml_lines.extend([
103
178
  ' <!-- Global Header Manager -->',
104
179
  ' <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">',
105
180
  ' <collectionProp name="HeaderManager.headers">',
@@ -115,6 +190,10 @@ def build_jmx_xml(
115
190
  ' <stringProp name="Header.name">Authorization</stringProp>',
116
191
  ' <stringProp name="Header.value">Bearer ${BEARER_TOKEN}</stringProp>',
117
192
  ' </elementProp>',
193
+ ' <elementProp name="" elementType="Header">',
194
+ ' <stringProp name="Header.name">X-CSRF-Token</stringProp>',
195
+ ' <stringProp name="Header.value">${CSRF_TOKEN}</stringProp>',
196
+ ' </elementProp>',
118
197
  ' </collectionProp>',
119
198
  ' </HeaderManager>',
120
199
  ' <hashTree/>',
@@ -127,10 +206,144 @@ def build_jmx_xml(
127
206
  ' <stringProp name="HTTPSampler.port">${PORT}</stringProp>',
128
207
  ' <stringProp name="HTTPSampler.protocol">${PROTOCOL}</stringProp>',
129
208
  ' <stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>',
209
+ ' <stringProp name="HTTPSampler.connect_timeout">5000</stringProp>',
210
+ ' <stringProp name="HTTPSampler.response_timeout">30000</stringProp>',
130
211
  ' </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">',
212
+ ' <hashTree/>'
213
+ ])
214
+
215
+ # InfluxDB Backend Listener for live Grafana streaming
216
+ if influxdb_url:
217
+ xml_lines.extend([
218
+ ' <!-- InfluxDB Live Telemetry Backend Listener (Grafana APM) -->',
219
+ ' <BackendListener guiclass="BackendListenerGui" testclass="BackendListener" testname="Backend Listener - InfluxDB" enabled="true">',
220
+ ' <elementProp name="arguments" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" enabled="true">',
221
+ ' <collectionProp name="Arguments.arguments">',
222
+ ' <elementProp name="influxdbMetricsSender" elementType="Argument">',
223
+ ' <stringProp name="Argument.name">influxdbMetricsSender</stringProp>',
224
+ ' <stringProp name="Argument.value">org.apache.jmeter.visualizers.backend.influxdb.HttpMetricsSender</stringProp>',
225
+ ' <stringProp name="Argument.metadata">=</stringProp>',
226
+ ' </elementProp>',
227
+ ' <elementProp name="influxdbUrl" elementType="Argument">',
228
+ ' <stringProp name="Argument.name">influxdbUrl</stringProp>',
229
+ f' <stringProp name="Argument.value">{xml_escape(influxdb_url)}</stringProp>',
230
+ ' <stringProp name="Argument.metadata">=</stringProp>',
231
+ ' </elementProp>',
232
+ ' <elementProp name="application" elementType="Argument">',
233
+ ' <stringProp name="Argument.name">application</stringProp>',
234
+ f' <stringProp name="Argument.value">{xml_escape(plan_name)}</stringProp>',
235
+ ' <stringProp name="Argument.metadata">=</stringProp>',
236
+ ' </elementProp>',
237
+ ' <elementProp name="measurement" elementType="Argument">',
238
+ ' <stringProp name="Argument.name">measurement</stringProp>',
239
+ ' <stringProp name="Argument.value">jmeter</stringProp>',
240
+ ' <stringProp name="Argument.metadata">=</stringProp>',
241
+ ' </elementProp>',
242
+ ' <elementProp name="summaryOnly" elementType="Argument">',
243
+ ' <stringProp name="Argument.name">summaryOnly</stringProp>',
244
+ ' <stringProp name="Argument.value">false</stringProp>',
245
+ ' <stringProp name="Argument.metadata">=</stringProp>',
246
+ ' </elementProp>',
247
+ ' <elementProp name="samplersRegex" elementType="Argument">',
248
+ ' <stringProp name="Argument.name">samplersRegex</stringProp>',
249
+ ' <stringProp name="Argument.value">.*</stringProp>',
250
+ ' <stringProp name="Argument.metadata">=</stringProp>',
251
+ ' </elementProp>',
252
+ ' <elementProp name="percentiles" elementType="Argument">',
253
+ ' <stringProp name="Argument.name">percentiles</stringProp>',
254
+ ' <stringProp name="Argument.value">90;95;99</stringProp>',
255
+ ' <stringProp name="Argument.metadata">=</stringProp>',
256
+ ' </elementProp>',
257
+ ' <elementProp name="testTitle" elementType="Argument">',
258
+ ' <stringProp name="Argument.name">testTitle</stringProp>',
259
+ f' <stringProp name="Argument.value">{xml_escape(plan_name)} Load Test</stringProp>',
260
+ ' <stringProp name="Argument.metadata">=</stringProp>',
261
+ ' </elementProp>',
262
+ ' </collectionProp>',
263
+ ' </elementProp>',
264
+ ' <stringProp name="classname">org.apache.jmeter.visualizers.backend.influxdb.InfluxdbBackendListenerClient</stringProp>',
265
+ ' </BackendListener>',
266
+ ' <hashTree/>'
267
+ ])
268
+
269
+ # PerfMon Server Agent Collector
270
+ if perfmon_server:
271
+ server_host = perfmon_server.split(":")[0]
272
+ server_port = perfmon_server.split(":")[1] if ":" in perfmon_server else "4444"
273
+ xml_lines.extend([
274
+ ' <!-- PerfMon Server Hardware Resource Metrics Collector -->',
275
+ ' <kg.apc.jmeter.perfmon.PerfMonCollector guiclass="kg.apc.jmeter.vizualizers.PerfMonGui" testclass="kg.apc.jmeter.perfmon.PerfMonCollector" testname="PerfMon Server Agent Metrics" enabled="true">',
276
+ ' <boolProp name="ResultCollector.error_logging">false</boolProp>',
277
+ ' <objProp>',
278
+ ' <name>saveConfig</name>',
279
+ ' <value class="SampleSaveConfiguration">',
280
+ ' <time>true</time><latency>true</latency><timestamp>true</timestamp><success>true</success>',
281
+ ' </value>',
282
+ ' </objProp>',
283
+ ' <stringProp name="filename">perfmon_server_metrics.csv</stringProp>',
284
+ ' <collectionProp name="metricConnections">',
285
+ ' <collectionProp name="">',
286
+ f' <stringProp name="">{xml_escape(server_host)}</stringProp>',
287
+ f' <stringProp name="">{xml_escape(server_port)}</stringProp>',
288
+ ' <stringProp name="">CPU</stringProp>',
289
+ ' <stringProp name="">combined</stringProp>',
290
+ ' </collectionProp>',
291
+ ' <collectionProp name="">',
292
+ f' <stringProp name="">{xml_escape(server_host)}</stringProp>',
293
+ f' <stringProp name="">{xml_escape(server_port)}</stringProp>',
294
+ ' <stringProp name="">Memory</stringProp>',
295
+ ' <stringProp name="">usedperc</stringProp>',
296
+ ' </collectionProp>',
297
+ ' <collectionProp name="">',
298
+ f' <stringProp name="">{xml_escape(server_host)}</stringProp>',
299
+ f' <stringProp name="">{xml_escape(server_port)}</stringProp>',
300
+ ' <stringProp name="">Disks I/O</stringProp>',
301
+ ' <stringProp name="">queue</stringProp>',
302
+ ' </collectionProp>',
303
+ ' </collectionProp>',
304
+ ' </kg.apc.jmeter.perfmon.PerfMonCollector>',
305
+ ' <hashTree/>'
306
+ ])
307
+
308
+ # JDBC Database Connection Configuration if requested
309
+ if jdbc_config:
310
+ pool_name = xml_escape(jdbc_config.get("pool_name", "EnterpriseDBPool"))
311
+ db_url = xml_escape(jdbc_config.get("url", "jdbc:postgresql://localhost:5432/enterprise_db"))
312
+ db_driver = xml_escape(jdbc_config.get("driver", "org.postgresql.Driver"))
313
+ db_user = xml_escape(jdbc_config.get("user", "postgres"))
314
+ db_pass = xml_escape(jdbc_config.get("password", "postgres"))
315
+ xml_lines.extend([
316
+ ' <!-- JDBC Database Connection Pool Configuration -->',
317
+ ' <JDBCDataSource guiclass="TestBeanGUI" testclass="JDBCDataSource" testname="JDBC Connection Configuration" enabled="true">',
318
+ ' <boolProp name="autocommit">true</boolProp>',
319
+ ' <stringProp name="checkQuery"></stringProp>',
320
+ ' <stringProp name="connectionAge">5000</stringProp>',
321
+ ' <stringProp name="connectionProperties"></stringProp>',
322
+ f' <stringProp name="dataSource">{pool_name}</stringProp>',
323
+ f' <stringProp name="dbUrl">{db_url}</stringProp>',
324
+ f' <stringProp name="driver">{db_driver}</stringProp>',
325
+ ' <stringProp name="initQuery"></stringProp>',
326
+ ' <boolProp name="keepAlive">true</boolProp>',
327
+ f' <stringProp name="password">{db_pass}</stringProp>',
328
+ ' <stringProp name="poolMax">20</stringProp>',
329
+ ' <stringProp name="timeout">10000</stringProp>',
330
+ ' <stringProp name="transactionIsolation">DEFAULT</stringProp>',
331
+ ' <stringProp name="trimInterval">60000</stringProp>',
332
+ f' <stringProp name="username">{db_user}</stringProp>',
333
+ ' <boolProp name="preinit">false</boolProp>',
334
+ ' </JDBCDataSource>',
335
+ ' <hashTree/>'
336
+ ])
337
+
338
+ # Workload Profile Thread Group Setup
339
+ tg_name = f"Workload Engine [{workload_profile.upper()}] ({vus} VUs)"
340
+ actual_duration = duration
341
+ if workload_profile == "soak" and duration < 3600:
342
+ actual_duration = 3600 # Default 1h for soak
343
+
344
+ xml_lines.extend([
345
+ f' <!-- Main Thread Group: {workload_profile.upper()} Profile -->',
346
+ f' <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="{tg_name}" enabled="true">',
134
347
  ' <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>',
135
348
  ' <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">',
136
349
  f' <boolProp name="LoopController.continue_forever">{continue_forever}</boolProp>',
@@ -139,15 +352,75 @@ def build_jmx_xml(
139
352
  f' <stringProp name="ThreadGroup.num_threads">${{__P(vus,{vus})}}</stringProp>',
140
353
  f' <stringProp name="ThreadGroup.ramp_time">${{__P(ramp_up,{ramp_up})}}</stringProp>',
141
354
  ' <boolProp name="ThreadGroup.scheduler">true</boolProp>',
142
- f' <stringProp name="ThreadGroup.duration">${{__P(duration,{duration})}}</stringProp>',
355
+ f' <stringProp name="ThreadGroup.duration">${{__P(duration,{actual_duration})}}</stringProp>',
143
356
  ' <stringProp name="ThreadGroup.delay">0</stringProp>',
144
357
  ' <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>',
145
358
  ' </ThreadGroup>',
146
359
  ' <hashTree>'
147
- ]
360
+ ])
361
+
362
+ # CSV Data Set Config (Dynamic credentials / entities)
363
+ if csv_file:
364
+ xml_lines.extend([
365
+ ' <!-- CSV Data Set Config (Multi-User Dynamic Data Parameterization) -->',
366
+ ' <CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="CSV Data Set Config" enabled="true">',
367
+ ' <stringProp name="delimiter">,</stringProp>',
368
+ ' <stringProp name="fileEncoding">UTF-8</stringProp>',
369
+ f' <stringProp name="filename">{xml_escape(csv_file)}</stringProp>',
370
+ ' <boolProp name="ignoreFirstLine">true</boolProp>',
371
+ ' <boolProp name="quotedData">false</boolProp>',
372
+ ' <boolProp name="recycle">true</boolProp>',
373
+ ' <stringProp name="shareMode">shareMode.all</stringProp>',
374
+ ' <boolProp name="stopThread">false</boolProp>',
375
+ f' <stringProp name="variableNames">{xml_escape(csv_variables)}</stringProp>',
376
+ ' </CSVDataSet>',
377
+ ' <hashTree/>'
378
+ ])
379
+
380
+ # JSR223 Groovy PreProcessor for HMAC-SHA256 Payload Signature
381
+ if jsr223_hmac:
382
+ xml_lines.extend([
383
+ ' <!-- JSR223 Groovy PreProcessor: HMAC-SHA256 Payload Signer -->',
384
+ ' <JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor" testname="JSR223 - HMAC Payload Signer" enabled="true">',
385
+ ' <stringProp name="scriptLanguage">groovy</stringProp>',
386
+ ' <stringProp name="parameters"></stringProp>',
387
+ ' <stringProp name="filename"></stringProp>',
388
+ ' <stringProp name="cacheKey">true</stringProp>',
389
+ ' <stringProp name="script"><![CDATA[',
390
+ 'import javax.crypto.Mac',
391
+ 'import javax.crypto.spec.SecretKeySpec',
392
+ '',
393
+ 'String secret = vars.get("HMAC_SECRET") ?: "SynapseEnterpriseVault#2026"',
394
+ 'String body = sampler.getArguments().getArgument(0)?.getValue() ?: ""',
395
+ 'String timestamp = String.valueOf(System.currentTimeMillis())',
396
+ '',
397
+ 'Mac mac = Mac.getInstance("HmacSHA256")',
398
+ 'mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"))',
399
+ 'byte[] rawHmac = mac.doFinal((timestamp + ":" + body).getBytes("UTF-8"))',
400
+ 'String signature = rawHmac.encodeHex().toString()',
401
+ '',
402
+ 'sampler.getHeaderManager().add(new org.apache.jmeter.protocol.http.control.Header("X-Signature", signature))',
403
+ 'sampler.getHeaderManager().add(new org.apache.jmeter.protocol.http.control.Header("X-Timestamp", timestamp))',
404
+ 'sampler.getHeaderManager().add(new org.apache.jmeter.protocol.http.control.Header("X-Correlation-ID", "CORR-" + UUID.randomUUID().toString()))',
405
+ ']]></stringProp>',
406
+ ' </JSR223PreProcessor>',
407
+ ' <hashTree/>'
408
+ ])
409
+
410
+ # Uniform Random Timer for realistic user think-time
411
+ if think_time_max > think_time_min and think_time_max > 0:
412
+ timer_range = think_time_max - think_time_min
413
+ xml_lines.extend([
414
+ ' <!-- Uniform Random Timer (Simulate Realistic User Think-Time) -->',
415
+ ' <UniformRandomTimer guiclass="UniformRandomTimerGui" testclass="UniformRandomTimer" testname="User Think-Time Timer" enabled="true">',
416
+ f' <stringProp name="ConstantTimer.delay">{think_time_min}</stringProp>',
417
+ f' <stringProp name="RandomTimer.range">{timer_range}</stringProp>',
418
+ ' </UniformRandomTimer>',
419
+ ' <hashTree/>'
420
+ ])
148
421
 
149
- # Generate each HTTP Sampler
150
- for idx, s in enumerate(samplers, 1):
422
+ def render_sampler(idx: int, s: Dict[str, Any], indent: str = " ") -> List[str]:
423
+ lines = []
151
424
  s_name = xml_escape(s.get("name", f"HTTP Request {idx}"))
152
425
  s_path = xml_escape(s.get("path", "/"))
153
426
  s_method = xml_escape(s.get("method", "GET").upper())
@@ -156,82 +429,247 @@ def build_jmx_xml(
156
429
  escaped_body = xml_escape(s_body) if has_body else ""
157
430
  expected_status = s.get("expected_status", 200)
158
431
 
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">'
432
+ lines.extend([
433
+ f'{indent}<!-- Sampler {idx}: [{s_method}] {s_name} -->',
434
+ f'{indent}<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="[{s_method}] {s_name}" enabled="true">',
435
+ f'{indent} <boolProp name="HTTPSampler.postBodyRaw">{str(has_body).lower()}</boolProp>',
436
+ f'{indent} <elementProp name="HTTPsampler.Arguments" elementType="Arguments">',
437
+ f'{indent} <collectionProp name="Arguments.arguments">'
165
438
  ])
166
439
 
167
440
  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>'
441
+ lines.extend([
442
+ f'{indent} <elementProp name="" elementType="HTTPArgument">',
443
+ f'{indent} <boolProp name="HTTPArgument.always_encode">false</boolProp>',
444
+ f'{indent} <stringProp name="Argument.value">{escaped_body}</stringProp>',
445
+ f'{indent} <stringProp name="Argument.metadata">=</stringProp>',
446
+ f'{indent} </elementProp>'
174
447
  ])
175
448
 
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/>'
449
+ lines.extend([
450
+ f'{indent} </collectionProp>',
451
+ f'{indent} </elementProp>',
452
+ f'{indent} <stringProp name="HTTPSampler.path">{s_path}</stringProp>',
453
+ f'{indent} <stringProp name="HTTPSampler.method">{s_method}</stringProp>',
454
+ f'{indent} <boolProp name="HTTPSampler.follow_redirects">true</boolProp>',
455
+ f'{indent} <boolProp name="HTTPSampler.auto_redirects">false</boolProp>',
456
+ f'{indent} <boolProp name="HTTPSampler.use_keepalive">true</boolProp>',
457
+ f'{indent} <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>',
458
+ f'{indent}</HTTPSamplerProxy>',
459
+ f'{indent}<hashTree>',
460
+ f'{indent} <!-- Response Status Assertion -->',
461
+ f'{indent} <ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Assert Status ({expected_status})" enabled="true">',
462
+ f'{indent} <collectionProp name="Asserion.test_strings">',
463
+ f'{indent} <stringProp name="{expected_status}">{expected_status}</stringProp>',
464
+ f'{indent} <stringProp name="200">200</stringProp>',
465
+ f'{indent} <stringProp name="201">201</stringProp>',
466
+ f'{indent} </collectionProp>',
467
+ f'{indent} <stringProp name="Assertion.test_field">Assertion.response_code</stringProp>',
468
+ f'{indent} <boolProp name="Assertion.assume_success">false</boolProp>',
469
+ f'{indent} <intProp name="Assertion.test_type">40</intProp>',
470
+ f'{indent} </ResponseAssertion>',
471
+ f'{indent} <hashTree/>',
472
+ f'{indent} <!-- SLA Latency Assertion -->',
473
+ f'{indent} <DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" testname="Assert SLA Latency (&lt; ${{SLA_P95}}ms)" enabled="true">',
474
+ f'{indent} <stringProp name="DurationAssertion.duration">${{SLA_P95}}</stringProp>',
475
+ f'{indent} </DurationAssertion>',
476
+ f'{indent} <hashTree/>'
204
477
  ])
205
478
 
206
- # If endpoint is auth or creates entity, inject JSON PostProcessor
479
+ # Deep Correlation Engine: CSRF Regex Extractor on HTML/Auth/Form responses
480
+ if deep_correlation:
481
+ lines.extend([
482
+ f'{indent} <!-- Deep Correlation: Regex Extract Hidden CSRF Token -->',
483
+ f'{indent} <RegexExtractor guiclass="RegexExtractorGui" testclass="RegexExtractor" testname="Regex - Extract CSRF Token" enabled="true">',
484
+ f'{indent} <stringProp name="RegexExtractor.useHeaders">false</stringProp>',
485
+ f'{indent} <stringProp name="RegexExtractor.refname">CSRF_TOKEN</stringProp>',
486
+ f'{indent} <stringProp name="RegexExtractor.regex"><![CDATA[(?:name=["\'](?:csrf[-_]?token|_token|authenticity_token)["\']\\s+value=["\']([^"\']+)["\'])|(?:content=["\']([^"\']+)["\']\\s+name=["\']csrf[-_]?token["\'])]]></stringProp>',
487
+ f'{indent} <stringProp name="RegexExtractor.template">$1$$2$</stringProp>',
488
+ f'{indent} <stringProp name="RegexExtractor.default">${{CSRF_TOKEN}}</stringProp>',
489
+ f'{indent} <stringProp name="RegexExtractor.match_number">1</stringProp>',
490
+ f'{indent} </RegexExtractor>',
491
+ f'{indent} <hashTree/>',
492
+ f'{indent} <!-- Deep Correlation: Random Array Item Selection (Match No: 0) -->',
493
+ f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Random Item Selector" enabled="true">',
494
+ f'{indent} <stringProp name="JSONPostProcessor.referenceNames">RANDOM_ITEM_ID</stringProp>',
495
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$..items[?(@.stock &gt; 0)].id, $..id, $..code</stringProp>',
496
+ f'{indent} <stringProp name="JSONPostProcessor.match_numbers">0</stringProp>',
497
+ f'{indent} <stringProp name="JSONPostProcessor.defaultValues">${{RANDOM_ITEM_ID}}</stringProp>',
498
+ f'{indent} </JSONPostProcessor>',
499
+ f'{indent} <hashTree/>'
500
+ ])
501
+
502
+ # Dynamic Token Extractor
207
503
  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/>'
504
+ lines.extend([
505
+ f'{indent} <!-- Auto-Extract Dynamic Bearer Token -->',
506
+ f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Bearer Token" enabled="true">',
507
+ f'{indent} <stringProp name="JSONPostProcessor.referenceNames">BEARER_TOKEN</stringProp>',
508
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$.token, $.access_token, $.data.token, $.data.access_token</stringProp>',
509
+ f'{indent} <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
510
+ f'{indent} <stringProp name="JSONPostProcessor.defaultValues">${{BEARER_TOKEN}}</stringProp>',
511
+ f'{indent} </JSONPostProcessor>',
512
+ f'{indent} <hashTree/>'
217
513
  ])
218
514
  elif s_method == "POST":
515
+ lines.extend([
516
+ f'{indent} <!-- Auto-Extract Created Entity ID -->',
517
+ f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Entity ID" enabled="true">',
518
+ f'{indent} <stringProp name="JSONPostProcessor.referenceNames">LAST_CREATED_ID</stringProp>',
519
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$.id, $.code, $.data.id, $.data.code</stringProp>',
520
+ f'{indent} <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
521
+ f'{indent} <stringProp name="JSONPostProcessor.defaultValues"></stringProp>',
522
+ f'{indent} </JSONPostProcessor>',
523
+ f'{indent} <hashTree/>'
524
+ ])
525
+
526
+ lines.append(f'{indent}</hashTree>')
527
+ return lines
528
+
529
+ # Render Samplers with optional Traffic Shaping (Weighted Throughput Controller)
530
+ if weighted_mix:
531
+ # Parse weights: e.g. "70,20,10" or "70:Browse,20:Cart,10:Checkout"
532
+ parts = [p.strip() for p in weighted_mix.split(",") if p.strip()]
533
+ weights: List[Tuple[float, str]] = []
534
+ for p in parts:
535
+ if ":" in p:
536
+ w_str, label = p.split(":", 1)
537
+ weights.append((float(w_str), label))
538
+ else:
539
+ weights.append((float(p), f"Route {len(weights) + 1}"))
540
+
541
+ # Partition samplers across weights
542
+ chunk_size = max(1, len(samplers) // len(weights)) if weights else len(samplers)
543
+ for w_idx, (weight, label) in enumerate(weights):
544
+ start_i = w_idx * chunk_size
545
+ end_i = start_i + chunk_size if w_idx < len(weights) - 1 else len(samplers)
546
+ group_samplers = samplers[start_i:end_i] if start_i < len(samplers) else samplers[-1:]
547
+
219
548
  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/>'
549
+ f' <!-- Traffic Shaping Controller: {weight}% Load ({label}) -->',
550
+ f' <ThroughputController guiclass="ThroughputControllerGui" testclass="ThroughputController" testname="Traffic Weight {weight}% - {xml_escape(label)}" enabled="true">',
551
+ ' <intProp name="ThroughputController.style">1</intProp>',
552
+ ' <boolProp name="ThroughputController.perThread">false</boolProp>',
553
+ f' <stringProp name="ThroughputController.maxThroughput">{weight}</stringProp>',
554
+ ' <FloatProperty>',
555
+ ' <name>ThroughputController.percentThroughput</name>',
556
+ f' <value>{weight}</value>',
557
+ ' <savedValue>0.0</savedValue>',
558
+ ' </FloatProperty>',
559
+ ' </ThroughputController>',
560
+ ' <hashTree>'
228
561
  ])
562
+ for idx, s in enumerate(group_samplers, 1):
563
+ xml_lines.extend(render_sampler(idx, s, indent=" "))
564
+ xml_lines.append(' </hashTree>')
229
565
 
566
+ elif group_transactions:
567
+ # Group samplers into Transaction Controllers by operation prefix or chunks
568
+ xml_lines.extend([
569
+ ' <!-- Transaction Controller: End-to-End User Journey Flow -->',
570
+ ' <TransactionController guiclass="TransactionControllerGui" testclass="TransactionController" testname="Transaction - End-to-End Business Flow" enabled="true">',
571
+ ' <boolProp name="TransactionController.includeTimers">false</boolProp>',
572
+ ' <boolProp name="TransactionController.parent">true</boolProp>',
573
+ ' </TransactionController>',
574
+ ' <hashTree>'
575
+ ])
576
+ for idx, s in enumerate(samplers, 1):
577
+ xml_lines.extend(render_sampler(idx, s, indent=" "))
230
578
  xml_lines.append(' </hashTree>')
579
+ else:
580
+ for idx, s in enumerate(samplers, 1):
581
+ xml_lines.extend(render_sampler(idx, s, indent=" "))
582
+
583
+ # Render JDBC Samplers if present
584
+ if jdbc_config and "queries" in jdbc_config:
585
+ pool_name = xml_escape(jdbc_config.get("pool_name", "EnterpriseDBPool"))
586
+ for q_idx, q in enumerate(jdbc_config["queries"], 1):
587
+ q_name = xml_escape(q.get("name", f"JDBC Query {q_idx}"))
588
+ sql = xml_escape(q.get("sql", "SELECT 1"))
589
+ q_type = xml_escape(q.get("type", "Select Statement"))
590
+ xml_lines.extend([
591
+ f' <!-- JDBC Sampler {q_idx}: {q_name} -->',
592
+ f' <JDBCSampler guiclass="TestBeanGUI" testclass="JDBCSampler" testname="[JDBC] {q_name}" enabled="true">',
593
+ f' <stringProp name="dataSource">{pool_name}</stringProp>',
594
+ f' <stringProp name="query">{sql}</stringProp>',
595
+ ' <stringProp name="queryArguments"></stringProp>',
596
+ ' <stringProp name="queryArgumentsTypes"></stringProp>',
597
+ f' <stringProp name="queryType">{q_type}</stringProp>',
598
+ ' <stringProp name="resultVariable"></stringProp>',
599
+ ' <stringProp name="variableNames"></stringProp>',
600
+ ' <stringProp name="resultSetHandler">Store as String</stringProp>',
601
+ ' </JDBCSampler>',
602
+ ' <hashTree/>'
603
+ ])
604
+
605
+ # Render WebSocket Samplers if present
606
+ if ws_config:
607
+ ws_server = xml_escape(ws_config.get("server", "localhost"))
608
+ ws_port = xml_escape(str(ws_config.get("port", "8080")))
609
+ ws_path = xml_escape(ws_config.get("path", "/ws/feed"))
610
+ xml_lines.extend([
611
+ ' <!-- WebSocket Lifecycle Samplers (Connect, Send, Receive, Close) -->',
612
+ ' <eu.luminis.jmeter.wssampler.OpenWebSocketSampler guiclass="eu.luminis.jmeter.wssampler.OpenWebSocketSamplerGui" testclass="eu.luminis.jmeter.wssampler.OpenWebSocketSampler" testname="[WS] Open Connection" enabled="true">',
613
+ ' <boolProp name="TLS">false</boolProp>',
614
+ f' <stringProp name="server">{ws_server}</stringProp>',
615
+ f' <stringProp name="port">{ws_port}</stringProp>',
616
+ f' <stringProp name="path">{ws_path}</stringProp>',
617
+ ' <stringProp name="connectTimeout">5000</stringProp>',
618
+ ' <stringProp name="readTimeout">10000</stringProp>',
619
+ ' </eu.luminis.jmeter.wssampler.OpenWebSocketSampler>',
620
+ ' <hashTree/>',
621
+ ' <eu.luminis.jmeter.wssampler.RequestResponseWebSocketSampler guiclass="eu.luminis.jmeter.wssampler.RequestResponseWebSocketSamplerGui" testclass="eu.luminis.jmeter.wssampler.RequestResponseWebSocketSampler" testname="[WS] Request-Response Frame" enabled="true">',
622
+ ' <boolProp name="createNewConnection">false</boolProp>',
623
+ ' <boolProp name="loadDataFromFile">false</boolProp>',
624
+ ' <stringProp name="requestData">{"event": "subscribe", "channel": "ticker", "pair": "BTC_USDT"}</stringProp>',
625
+ ' <stringProp name="readTimeout">5000</stringProp>',
626
+ ' </eu.luminis.jmeter.wssampler.RequestResponseWebSocketSampler>',
627
+ ' <hashTree/>',
628
+ ' <eu.luminis.jmeter.wssampler.CloseWebSocketSampler guiclass="eu.luminis.jmeter.wssampler.CloseWebSocketSamplerGui" testclass="eu.luminis.jmeter.wssampler.CloseWebSocketSampler" testname="[WS] Close Connection" enabled="true">',
629
+ ' <stringProp name="statusCode">1000</stringProp>',
630
+ ' <stringProp name="readTimeout">3000</stringProp>',
631
+ ' </eu.luminis.jmeter.wssampler.CloseWebSocketSampler>',
632
+ ' <hashTree/>'
633
+ ])
634
+
635
+ # Render gRPC Samplers if present
636
+ if grpc_config:
637
+ grpc_host = xml_escape(grpc_config.get("host", "localhost"))
638
+ grpc_port = xml_escape(str(grpc_config.get("port", "50051")))
639
+ grpc_service = xml_escape(grpc_config.get("service", "enterprise.OrderService/ProcessOrder"))
640
+ xml_lines.extend([
641
+ ' <!-- gRPC Microservice RPC Sampler -->',
642
+ ' <vn.zalopay.benchmark.core.protobuf.ProtobufSampler guiclass="TestBeanGUI" testclass="vn.zalopay.benchmark.core.protobuf.ProtobufSampler" testname="[gRPC] Unary RPC Call" enabled="true">',
643
+ f' <stringProp name="hostname">{grpc_host}</stringProp>',
644
+ f' <stringProp name="port">{grpc_port}</stringProp>',
645
+ f' <stringProp name="fullMethod">{grpc_service}</stringProp>',
646
+ ' <boolProp name="useTls">false</boolProp>',
647
+ ' <stringProp name="protoFolder">proto</stringProp>',
648
+ ' <stringProp name="libFolder">proto/libs</stringProp>',
649
+ ' <stringProp name="requestJson">{"order_id": "${LAST_CREATED_ID}", "amount": 250000, "currency": "VND"}</stringProp>',
650
+ ' <stringProp name="deadline">5000</stringProp>',
651
+ ' </vn.zalopay.benchmark.core.protobuf.ProtobufSampler>',
652
+ ' <hashTree/>'
653
+ ])
654
+
655
+ # Render Kafka Samplers if present
656
+ if kafka_config:
657
+ kafka_brokers = xml_escape(kafka_config.get("brokers", "localhost:9092"))
658
+ kafka_topic = xml_escape(kafka_config.get("topic", "enterprise.orders.stream"))
659
+ xml_lines.extend([
660
+ ' <!-- Kafka Producer Event-Driven Message Sampler -->',
661
+ ' <org.apache.jmeter.protocol.jms.sampler.PublisherSampler guiclass="TestBeanGUI" testclass="org.apache.jmeter.protocol.jms.sampler.PublisherSampler" testname="[Kafka] Stream Message Producer" enabled="true">',
662
+ ' <stringProp name="jndiConfig">org.apache.kafka.common.security.plain.PlainLoginModule</stringProp>',
663
+ f' <stringProp name="destination">{kafka_topic}</stringProp>',
664
+ f' <stringProp name="providerUrl">{kafka_brokers}</stringProp>',
665
+ ' <stringProp name="textMessage">{"timestamp": "${__time()}", "correlation_id": "CORR-${__UUID()}", "event": "ORDER_SUBMITTED"}</stringProp>',
666
+ ' </org.apache.jmeter.protocol.jms.sampler.PublisherSampler>',
667
+ ' <hashTree/>'
668
+ ])
231
669
 
232
- # Add Summary Report Result Collector
670
+ # Result Collectors for CLI JTL export and Summary
233
671
  xml_lines.extend([
234
- ' <!-- Headless Summary Report Collector -->',
672
+ ' <!-- Headless JTL Result Collector & Summary Report -->',
235
673
  ' <ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report" enabled="true">',
236
674
  ' <boolProp name="ResultCollector.error_logging">false</boolProp>',
237
675
  ' <objProp>',
@@ -283,7 +721,6 @@ def parse_openapi_for_jmeter(spec_data: Dict[str, Any]) -> tuple:
283
721
  info_dict = spec_data.get("info", {})
284
722
  plan_name = info_dict.get("title", "Enterprise Performance Test Plan")
285
723
 
286
- # Host / Protocol / Port extraction
287
724
  base_host = "api.staging.enterprise.local"
288
725
  protocol = "https"
289
726
  port = "443"
@@ -313,8 +750,6 @@ def parse_openapi_for_jmeter(spec_data: Dict[str, Any]) -> tuple:
313
750
  op = path_item[method]
314
751
  summary = op.get("summary") or f"{method.upper()} {path_str}"
315
752
 
316
- # Convert path variables: {id} -> ${LAST_CREATED_ID}, {param} -> ${param}
317
- import re
318
753
  def replace_path_param(match):
319
754
  pname = match.group(1)
320
755
  if pname.lower() in ("id", "categoryid", "entityid", "userid"):
@@ -390,8 +825,6 @@ def parse_postman_for_jmeter(postman_data: Dict[str, Any]) -> tuple:
390
825
  path_parts = url_obj.get("path", [])
391
826
  path = "/" + "/".join(path_parts) if path_parts else "/"
392
827
 
393
- # Convert Postman {{var}} to JMeter ${var}
394
- import re
395
828
  jmeter_path = re.sub(r'\{\{([a-zA-Z0-9_]+)\}\}', r'${\1}', path)
396
829
 
397
830
  body_str = ""
@@ -412,24 +845,142 @@ def parse_postman_for_jmeter(postman_data: Dict[str, Any]) -> tuple:
412
845
  return plan_name, base_host, protocol, port, samplers
413
846
 
414
847
 
848
+ def parse_har_for_jmeter(har_data: Dict[str, Any]) -> tuple:
849
+ """Extracts samplers from HTTP Archive (HAR)."""
850
+ entries = har_data.get("log", {}).get("entries", [])
851
+ plan_name = "HAR Recorded Performance Test Plan"
852
+ base_host = "api.staging.enterprise.local"
853
+ protocol = "https"
854
+ port = "443"
855
+ samplers = []
856
+
857
+ if entries:
858
+ first_url = entries[0].get("request", {}).get("url", "")
859
+ p = urlparse(first_url)
860
+ if p.netloc:
861
+ base_host = p.hostname or base_host
862
+ protocol = p.scheme or protocol
863
+ port = str(p.port) if p.port else ("443" if protocol == "https" else "80")
864
+
865
+ for idx, e in enumerate(entries, 1):
866
+ req = e.get("request", {})
867
+ url = req.get("url", "/")
868
+ p = urlparse(url)
869
+ method = req.get("method", "GET").upper()
870
+
871
+ post_data = req.get("postData", {})
872
+ body_str = post_data.get("text", "")
873
+ status_code = e.get("response", {}).get("status", 200)
874
+
875
+ samplers.append({
876
+ "name": f"HAR Step {idx}: {p.path or '/'}",
877
+ "path": p.path + (f"?{p.query}" if p.query else ""),
878
+ "method": method,
879
+ "body": body_str,
880
+ "expected_status": status_code if status_code > 0 else 200
881
+ })
882
+
883
+ return plan_name, base_host, protocol, port, samplers
884
+
885
+
415
886
  def main():
416
887
  parser = argparse.ArgumentParser(
417
- description="Generate Apache JMeter 5.x Test Plan XML (.jmx) from OpenAPI, Postman, or HAR."
888
+ description="Generate Apache JMeter 5.x Test Plan XML (.jmx) with Pinnacle JMeter 360° Capabilities."
418
889
  )
419
- parser.add_argument("--spec", "-s", help="Path to OpenAPI / Swagger JSON specification file.")
890
+ parser.add_argument("--spec", "-s", help="Path to OpenAPI / Swagger JSON/YAML specification file.")
420
891
  parser.add_argument("--postman", "-p", help="Path to Postman Collection JSON file (v2.1.0).")
892
+ parser.add_argument("--har", help="Path to HTTP Archive (.har) recording file.")
421
893
  parser.add_argument("--base-url", "-b", help="Target base host URL (e.g. https://api.staging.enterprise.local).")
422
894
  parser.add_argument("--vus", type=int, default=50, help="Number of concurrent Virtual Users (threads). Default: 50.")
423
895
  parser.add_argument("--ramp-up", type=int, default=30, help="Ramp-up duration in seconds. Default: 30.")
424
896
  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.")
897
+ parser.add_argument("--loop-count", type=int, default=-1, help="Loop iterations count (-1 for infinite). Default: -1.")
426
898
  parser.add_argument("--sla-p95", type=int, default=500, help="P95 Latency SLA assertion threshold in ms. Default: 500.")
427
899
  parser.add_argument("--output", "-o", help="Target output JMeter test plan file path (.jmx).")
428
900
  parser.add_argument("--sample", action="store_true", help="Generate a sample load test plan using built-in enterprise schema.")
901
+
902
+ # Advanced JMeter 360° Extensions
903
+ parser.add_argument("--no-cookies", action="store_true", help="Disable automatic HTTP Cookie Manager.")
904
+ parser.add_argument("--no-cache", action="store_true", help="Disable automatic HTTP Cache Manager.")
905
+ parser.add_argument("--no-deep-correlation", action="store_true", help="Disable CSRF Regex and Random Item Selector.")
906
+ parser.add_argument("--csv-data", help="Path to CSV file for dynamic multi-user test data parameterization.")
907
+ parser.add_argument("--csv-vars", default="username,password,entity_id", help="Comma-separated variable names for CSV Data Set.")
908
+ parser.add_argument("--group-transactions", action="store_true", help="Wrap endpoints in Transaction Controllers to measure end-to-end flow SLA.")
909
+ parser.add_argument("--weighted-mix", help="Traffic shaping weight mix, e.g. '70,20,10' or '70:Browse,20:Cart,10:Checkout'.")
910
+ parser.add_argument("--workload", choices=["linear", "step", "spike", "soak"], default="linear", help="Workload profile shape.")
911
+ parser.add_argument("--jsr223-hmac", action="store_true", help="Inject JSR223 Groovy PreProcessor for HMAC-SHA256 payload signatures.")
912
+ parser.add_argument("--influxdb-url", help="InfluxDB HTTP metrics endpoint for live Grafana APM dashboard streaming.")
913
+ parser.add_argument("--perfmon-server", help="PerfMon Server Agent host:port (e.g. 192.168.1.100:4444) for hardware telemetry.")
914
+ parser.add_argument("--think-time-min", type=int, default=0, help="Minimum simulated user think time in ms.")
915
+ parser.add_argument("--think-time-max", type=int, default=0, help="Maximum simulated user think time in ms.")
916
+
917
+ # Multi-Protocol Samples
918
+ parser.add_argument("--jdbc-sample", action="store_true", help="Generate a sample JDBC database benchmark test plan.")
919
+ parser.add_argument("--ws-sample", action="store_true", help="Generate a sample WebSocket real-time lifecycle test plan.")
920
+ parser.add_argument("--grpc-sample", action="store_true", help="Generate a sample gRPC microservice unary RPC test plan.")
921
+ parser.add_argument("--kafka-sample", action="store_true", help="Generate a sample Kafka stream message producer test plan.")
429
922
 
430
923
  args = parser.parse_args()
431
924
 
432
- if args.postman:
925
+ jdbc_config = None
926
+ ws_config = None
927
+ grpc_config = None
928
+ kafka_config = None
929
+
930
+ if args.ws_sample:
931
+ plan_name = "Enterprise WebSocket Real-Time Stream Benchmark"
932
+ base_host = "localhost"
933
+ protocol = "ws"
934
+ port = "8080"
935
+ samplers = []
936
+ ws_config = {
937
+ "server": "localhost",
938
+ "port": 8080,
939
+ "path": "/ws/feed"
940
+ }
941
+ elif args.grpc_sample:
942
+ plan_name = "Enterprise gRPC Microservices Benchmark"
943
+ base_host = "localhost"
944
+ protocol = "grpc"
945
+ port = "50051"
946
+ samplers = []
947
+ grpc_config = {
948
+ "host": "localhost",
949
+ "port": 50051,
950
+ "service": "enterprise.OrderService/ProcessOrder"
951
+ }
952
+ elif args.kafka_sample:
953
+ plan_name = "Enterprise Kafka Event Stream Benchmark"
954
+ base_host = "localhost"
955
+ protocol = "kafka"
956
+ port = "9092"
957
+ samplers = []
958
+ kafka_config = {
959
+ "brokers": "localhost:9092",
960
+ "topic": "enterprise.orders.stream"
961
+ }
962
+ elif args.jdbc_sample:
963
+ plan_name = "Enterprise Database Performance Benchmark (JDBC)"
964
+ base_host = "localhost"
965
+ protocol = "http"
966
+ port = "80"
967
+ samplers = []
968
+ jdbc_config = {
969
+ "pool_name": "PostgresEnterprisePool",
970
+ "url": "jdbc:postgresql://localhost:5432/enterprise_db",
971
+ "driver": "org.postgresql.Driver",
972
+ "user": "postgres",
973
+ "password": "SecretDBPassword#2026",
974
+ "queries": [
975
+ {"name": "Query High-Frequency Catalogs", "sql": "SELECT id, code, name, price FROM catalogs WHERE active = true LIMIT 50;", "type": "Select Statement"},
976
+ {"name": "Insert Synthetic Audit Log", "sql": "INSERT INTO audit_logs(event_name, created_at) VALUES ('PERF_LOAD_RUN', NOW());", "type": "Update Statement"}
977
+ ]
978
+ }
979
+ elif args.har and os.path.exists(args.har):
980
+ with open(args.har, "r", encoding="utf-8") as f:
981
+ har_data = json.load(f)
982
+ plan_name, base_host, protocol, port, samplers = parse_har_for_jmeter(har_data)
983
+ elif args.postman:
433
984
  if not os.path.exists(args.postman):
434
985
  print(f"❌ Error: Postman collection not found: {args.postman}", file=sys.stderr)
435
986
  sys.exit(1)
@@ -466,7 +1017,24 @@ def main():
466
1017
  duration=args.duration,
467
1018
  loop_count=args.loop_count,
468
1019
  sla_p95=args.sla_p95,
469
- samplers=samplers
1020
+ samplers=samplers,
1021
+ enable_cookies=not args.no_cookies,
1022
+ enable_cache=not args.no_cache,
1023
+ csv_file=args.csv_data,
1024
+ csv_variables=args.csv_vars,
1025
+ group_transactions=args.group_transactions,
1026
+ weighted_mix=args.weighted_mix,
1027
+ deep_correlation=not args.no_deep_correlation,
1028
+ workload_profile=args.workload,
1029
+ jsr223_hmac=args.jsr223_hmac,
1030
+ influxdb_url=args.influxdb_url,
1031
+ perfmon_server=args.perfmon_server,
1032
+ think_time_min=args.think_time_min,
1033
+ think_time_max=args.think_time_max,
1034
+ jdbc_config=jdbc_config,
1035
+ ws_config=ws_config,
1036
+ grpc_config=grpc_config,
1037
+ kafka_config=kafka_config
470
1038
  )
471
1039
 
472
1040
  if args.output:
@@ -476,8 +1044,10 @@ def main():
476
1044
  with open(args.output, "w", encoding="utf-8") as f:
477
1045
  f.write(jmx_content)
478
1046
  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")
1047
+ print(f"📊 Profile: {args.workload.upper()} | {args.vus} VUs | {args.ramp_up}s Ramp-up | {args.duration}s Duration | SLA < {args.sla_p95}ms")
1048
+ print(f"🛡️ Capabilities: Cookies={'ON' if not args.no_cookies else 'OFF'} | Cache={'ON' if not args.no_cache else 'OFF'} | DeepCorrelation={'ON' if not args.no_deep_correlation else 'OFF'} | WeightedMix={args.weighted_mix or 'None'}")
1049
+ print(f"🚀 Headless Execution: jmeter -n -t \"{args.output}\" -l results.jtl -e -o ./html_report")
1050
+ print(f"🎯 CI/CD SLA Gatekeeper: agent-qa analyze-jtl results.jtl --sla-p95 {args.sla_p95} --max-error-rate 1.0")
481
1051
  else:
482
1052
  print(jmx_content)
483
1053