synapse-hub 2.0.0 → 2.1.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.
- package/.agents/rules/git-push-protocol.md +1 -1
- package/.agents/rules/lessons-learned.md +1 -0
- package/.agents/skills/api-spec-to-e2e-matrix/SKILL.md +8 -5
- package/.agents/skills/backend-load-and-stress-patterns/SKILL.md +13 -0
- package/README.md +172 -29
- package/package.json +9 -5
- package/skills_manifest.json +260 -260
- package/tools/TOOLS_RUNBOOK.md +54 -3
- package/tools/api_fuzzer.py +468 -0
- package/tools/browser_helpers.js +2 -2
- package/tools/bump_version.py +211 -0
- package/tools/cache_resolver.py +128 -0
- package/tools/cli.py +41 -1
- package/tools/export_postman_collection.py +606 -0
- package/tools/flaky_detector.py +180 -14
- package/tools/generate_jmx_plan.py +486 -0
- package/tools/mcp_excel_server.ps1 +1 -1
- package/tools/mcp_server.py +1 -1
- package/tools/micro_mutator.py +56 -0
- package/tools/network_mock_shield.py +141 -0
- package/tools/repo_map.json +1 -1
- package/tools/repo_map.txt +1 -1
- package/tools/visual_som_helper.py +128 -9
|
@@ -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 (< ${{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.
|
|
163
|
+
serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.1.0" }
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
[System.Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress -Depth 10))
|
package/tools/mcp_server.py
CHANGED
package/tools/micro_mutator.py
CHANGED
|
@@ -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
|