pytest-dsl 0.1.0__py3-none-any.whl
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.
- pytest_dsl/__init__.py +10 -0
- pytest_dsl/cli.py +44 -0
- pytest_dsl/conftest_adapter.py +4 -0
- pytest_dsl/core/__init__.py +0 -0
- pytest_dsl/core/auth_provider.py +409 -0
- pytest_dsl/core/auto_decorator.py +181 -0
- pytest_dsl/core/auto_directory.py +81 -0
- pytest_dsl/core/context.py +23 -0
- pytest_dsl/core/custom_auth_example.py +425 -0
- pytest_dsl/core/dsl_executor.py +329 -0
- pytest_dsl/core/dsl_executor_utils.py +84 -0
- pytest_dsl/core/global_context.py +103 -0
- pytest_dsl/core/http_client.py +411 -0
- pytest_dsl/core/http_request.py +810 -0
- pytest_dsl/core/keyword_manager.py +109 -0
- pytest_dsl/core/lexer.py +139 -0
- pytest_dsl/core/parser.py +197 -0
- pytest_dsl/core/parsetab.py +76 -0
- pytest_dsl/core/plugin_discovery.py +187 -0
- pytest_dsl/core/utils.py +146 -0
- pytest_dsl/core/variable_utils.py +267 -0
- pytest_dsl/core/yaml_loader.py +62 -0
- pytest_dsl/core/yaml_vars.py +75 -0
- pytest_dsl/docs/custom_keywords.md +140 -0
- pytest_dsl/examples/__init__.py +5 -0
- pytest_dsl/examples/assert/assertion_example.auto +44 -0
- pytest_dsl/examples/assert/boolean_test.auto +34 -0
- pytest_dsl/examples/assert/expression_test.auto +49 -0
- pytest_dsl/examples/http/__init__.py +3 -0
- pytest_dsl/examples/http/builtin_auth_test.auto +79 -0
- pytest_dsl/examples/http/csrf_auth_test.auto +64 -0
- pytest_dsl/examples/http/custom_auth_test.auto +76 -0
- pytest_dsl/examples/http/file_reference_test.auto +111 -0
- pytest_dsl/examples/http/http_advanced.auto +91 -0
- pytest_dsl/examples/http/http_example.auto +147 -0
- pytest_dsl/examples/http/http_length_test.auto +55 -0
- pytest_dsl/examples/http/http_retry_assertions.auto +91 -0
- pytest_dsl/examples/http/http_retry_assertions_enhanced.auto +94 -0
- pytest_dsl/examples/http/http_with_yaml.auto +58 -0
- pytest_dsl/examples/http/new_retry_test.auto +22 -0
- pytest_dsl/examples/http/retry_assertions_only.auto +52 -0
- pytest_dsl/examples/http/retry_config_only.auto +49 -0
- pytest_dsl/examples/http/retry_debug.auto +22 -0
- pytest_dsl/examples/http/retry_with_fix.auto +21 -0
- pytest_dsl/examples/http/simple_retry.auto +20 -0
- pytest_dsl/examples/http/vars.yaml +55 -0
- pytest_dsl/examples/http_clients.yaml +48 -0
- pytest_dsl/examples/keyword_example.py +70 -0
- pytest_dsl/examples/test_assert.py +16 -0
- pytest_dsl/examples/test_http.py +168 -0
- pytest_dsl/keywords/__init__.py +10 -0
- pytest_dsl/keywords/assertion_keywords.py +610 -0
- pytest_dsl/keywords/global_keywords.py +51 -0
- pytest_dsl/keywords/http_keywords.py +430 -0
- pytest_dsl/keywords/system_keywords.py +17 -0
- pytest_dsl/main_adapter.py +7 -0
- pytest_dsl/plugin.py +44 -0
- pytest_dsl-0.1.0.dist-info/METADATA +537 -0
- pytest_dsl-0.1.0.dist-info/RECORD +63 -0
- pytest_dsl-0.1.0.dist-info/WHEEL +5 -0
- pytest_dsl-0.1.0.dist-info/entry_points.txt +5 -0
- pytest_dsl-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytest_dsl-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,810 @@
|
|
1
|
+
import json
|
2
|
+
import re
|
3
|
+
import yaml
|
4
|
+
import jsonpath_ng.ext as jsonpath
|
5
|
+
from typing import Dict, List, Any, Union, Optional, Tuple
|
6
|
+
import lxml.etree as etree
|
7
|
+
from requests import Response
|
8
|
+
import allure
|
9
|
+
import requests
|
10
|
+
|
11
|
+
from pytest_dsl.core.http_client import http_client_manager
|
12
|
+
|
13
|
+
|
14
|
+
class HTTPRequest:
|
15
|
+
"""HTTP请求处理类
|
16
|
+
|
17
|
+
负责处理HTTP请求、响应捕获和断言
|
18
|
+
"""
|
19
|
+
|
20
|
+
def __init__(self, config: Dict[str, Any], client_name: str = "default", session_name: str = None):
|
21
|
+
"""初始化HTTP请求
|
22
|
+
|
23
|
+
Args:
|
24
|
+
config: 请求配置
|
25
|
+
client_name: 客户端名称
|
26
|
+
session_name: 会话名称(如果需要使用命名会话)
|
27
|
+
"""
|
28
|
+
self.config = config
|
29
|
+
self.client_name = client_name
|
30
|
+
self.session_name = session_name
|
31
|
+
self.response = None
|
32
|
+
self.captured_values = {}
|
33
|
+
|
34
|
+
def execute(self, disable_auth: bool = False) -> Response:
|
35
|
+
"""执行HTTP请求
|
36
|
+
|
37
|
+
Args:
|
38
|
+
disable_auth: 是否禁用认证
|
39
|
+
|
40
|
+
Returns:
|
41
|
+
Response对象
|
42
|
+
"""
|
43
|
+
# 获取HTTP客户端
|
44
|
+
if self.session_name:
|
45
|
+
client = http_client_manager.get_session(self.session_name, self.client_name)
|
46
|
+
else:
|
47
|
+
client = http_client_manager.get_client(self.client_name)
|
48
|
+
|
49
|
+
# 验证客户端有效性
|
50
|
+
if client is None:
|
51
|
+
error_message = f"无法获取HTTP客户端: {self.client_name}"
|
52
|
+
allure.attach(
|
53
|
+
error_message,
|
54
|
+
name="HTTP客户端错误",
|
55
|
+
attachment_type=allure.attachment_type.TEXT
|
56
|
+
)
|
57
|
+
raise ValueError(error_message)
|
58
|
+
|
59
|
+
# 提取请求参数
|
60
|
+
method = self.config.get('method', 'GET').upper()
|
61
|
+
url = self.config.get('url', '')
|
62
|
+
|
63
|
+
# 配置中是否禁用认证
|
64
|
+
disable_auth = disable_auth or self.config.get('disable_auth', False)
|
65
|
+
|
66
|
+
request_config = self.config.get('request', {})
|
67
|
+
|
68
|
+
# 构建请求参数
|
69
|
+
request_kwargs = {
|
70
|
+
'params': request_config.get('params'),
|
71
|
+
'headers': request_config.get('headers'),
|
72
|
+
'json': request_config.get('json'),
|
73
|
+
'data': request_config.get('data'),
|
74
|
+
'files': request_config.get('files'),
|
75
|
+
'cookies': request_config.get('cookies'),
|
76
|
+
'auth': tuple(request_config.get('auth')) if request_config.get('auth') else None,
|
77
|
+
'timeout': request_config.get('timeout'),
|
78
|
+
'allow_redirects': request_config.get('allow_redirects'),
|
79
|
+
'verify': request_config.get('verify'),
|
80
|
+
'cert': request_config.get('cert'),
|
81
|
+
'proxies': request_config.get('proxies'),
|
82
|
+
'disable_auth': disable_auth # 传递禁用认证标志
|
83
|
+
}
|
84
|
+
|
85
|
+
# 过滤掉None值
|
86
|
+
request_kwargs = {k: v for k, v in request_kwargs.items() if v is not None}
|
87
|
+
|
88
|
+
# 使用Allure记录请求信息
|
89
|
+
self._log_request_to_allure(method, url, request_kwargs)
|
90
|
+
|
91
|
+
try:
|
92
|
+
# 发送请求
|
93
|
+
self.response = client.make_request(method, url, **request_kwargs)
|
94
|
+
|
95
|
+
# 使用Allure记录响应信息
|
96
|
+
self._log_response_to_allure(self.response)
|
97
|
+
|
98
|
+
# 处理捕获
|
99
|
+
self.process_captures()
|
100
|
+
|
101
|
+
return self.response
|
102
|
+
except requests.exceptions.RequestException as e:
|
103
|
+
# 记录请求异常到Allure
|
104
|
+
error_message = f"请求异常: {str(e)}"
|
105
|
+
allure.attach(
|
106
|
+
error_message,
|
107
|
+
name=f"HTTP请求失败: {method} {url}",
|
108
|
+
attachment_type=allure.attachment_type.TEXT
|
109
|
+
)
|
110
|
+
|
111
|
+
# 重新抛出更有意义的异常
|
112
|
+
raise ValueError(f"HTTP请求失败: {str(e)}") from e
|
113
|
+
except Exception as e:
|
114
|
+
# 捕获所有其他异常
|
115
|
+
error_message = f"未预期的异常: {type(e).__name__}: {str(e)}"
|
116
|
+
allure.attach(
|
117
|
+
error_message,
|
118
|
+
name=f"HTTP请求执行错误: {method} {url}",
|
119
|
+
attachment_type=allure.attachment_type.TEXT
|
120
|
+
)
|
121
|
+
|
122
|
+
# 重新抛出异常
|
123
|
+
raise ValueError(f"HTTP请求执行错误: {str(e)}") from e
|
124
|
+
|
125
|
+
def process_captures(self) -> Dict[str, Any]:
|
126
|
+
"""处理响应捕获
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
捕获的值字典
|
130
|
+
"""
|
131
|
+
if not self.response:
|
132
|
+
error_message = "需要先执行请求才能捕获响应"
|
133
|
+
# 记录更详细的错误信息到Allure
|
134
|
+
debug_info = (
|
135
|
+
f"错误详情: self.response 为 None\n"
|
136
|
+
f"配置信息: {json.dumps(self.config, indent=2, ensure_ascii=False, default=str)}\n"
|
137
|
+
f"当前状态: 客户端名称={self.client_name}, 会话名称={self.session_name}"
|
138
|
+
)
|
139
|
+
allure.attach(
|
140
|
+
debug_info,
|
141
|
+
name="捕获失败详情",
|
142
|
+
attachment_type=allure.attachment_type.TEXT
|
143
|
+
)
|
144
|
+
raise ValueError(error_message)
|
145
|
+
|
146
|
+
captures_config = self.config.get('captures', {})
|
147
|
+
|
148
|
+
for var_name, capture_spec in captures_config.items():
|
149
|
+
if not isinstance(capture_spec, list):
|
150
|
+
raise ValueError(f"无效的捕获规格: {var_name}: {capture_spec}")
|
151
|
+
|
152
|
+
# 提取捕获信息
|
153
|
+
try:
|
154
|
+
extractor_type = capture_spec[0]
|
155
|
+
extraction_path = capture_spec[1] if len(capture_spec) > 1 else None
|
156
|
+
|
157
|
+
# 检查是否有length参数
|
158
|
+
is_length_capture = False
|
159
|
+
if len(capture_spec) > 2 and capture_spec[2] == "length":
|
160
|
+
is_length_capture = True
|
161
|
+
default_value = capture_spec[3] if len(capture_spec) > 3 else None
|
162
|
+
else:
|
163
|
+
default_value = capture_spec[2] if len(capture_spec) > 2 else None
|
164
|
+
|
165
|
+
# 提取值
|
166
|
+
captured_value = self._extract_value(extractor_type, extraction_path, default_value)
|
167
|
+
|
168
|
+
# 特殊处理length
|
169
|
+
if is_length_capture:
|
170
|
+
try:
|
171
|
+
original_value = captured_value
|
172
|
+
captured_value = len(captured_value)
|
173
|
+
|
174
|
+
# 记录长度到Allure
|
175
|
+
allure.attach(
|
176
|
+
f"变量名: {var_name}\n提取器: {extractor_type}\n路径: {extraction_path}\n原始值: {str(original_value)}\n长度: {captured_value}",
|
177
|
+
name=f"捕获长度: {var_name}",
|
178
|
+
attachment_type=allure.attachment_type.TEXT
|
179
|
+
)
|
180
|
+
except Exception as e:
|
181
|
+
# 如果无法计算长度,记录错误并添加请求和响应信息
|
182
|
+
error_msg = f"变量名: {var_name}\n提取器: {extractor_type}\n路径: {extraction_path}\n错误: 无法计算长度: {str(e)}"
|
183
|
+
|
184
|
+
# 添加请求信息
|
185
|
+
error_msg += "\n\n=== 请求信息 ==="
|
186
|
+
error_msg += f"\nMethod: {self.config.get('method', 'GET')}"
|
187
|
+
error_msg += f"\nURL: {self.config.get('url', '')}"
|
188
|
+
if 'headers' in self.config.get('request', {}):
|
189
|
+
error_msg += "\nHeaders: " + str(self.config.get('request', {}).get('headers', {}))
|
190
|
+
if 'params' in self.config.get('request', {}):
|
191
|
+
error_msg += "\nParams: " + str(self.config.get('request', {}).get('params', {}))
|
192
|
+
if 'json' in self.config.get('request', {}):
|
193
|
+
error_msg += "\nJSON Body: " + str(self.config.get('request', {}).get('json', {}))
|
194
|
+
|
195
|
+
# 添加响应信息
|
196
|
+
error_msg += "\n\n=== 响应信息 ==="
|
197
|
+
error_msg += f"\nStatus: {self.response.status_code} {self.response.reason}"
|
198
|
+
error_msg += f"\nHeaders: {dict(self.response.headers)}"
|
199
|
+
try:
|
200
|
+
if 'application/json' in self.response.headers.get('Content-Type', ''):
|
201
|
+
error_msg += f"\nBody: {json.dumps(self.response.json(), ensure_ascii=False)}"
|
202
|
+
else:
|
203
|
+
error_msg += f"\nBody: {self.response.text}"
|
204
|
+
except:
|
205
|
+
error_msg += "\nBody: <无法解析响应体>"
|
206
|
+
|
207
|
+
allure.attach(
|
208
|
+
error_msg,
|
209
|
+
name=f"捕获长度失败: {var_name}",
|
210
|
+
attachment_type=allure.attachment_type.TEXT
|
211
|
+
)
|
212
|
+
captured_value = 0 # 默认长度
|
213
|
+
else:
|
214
|
+
# 记录捕获到Allure
|
215
|
+
allure.attach(
|
216
|
+
f"变量名: {var_name}\n提取器: {extractor_type}\n路径: {extraction_path}\n提取值: {str(captured_value)}",
|
217
|
+
name=f"捕获变量: {var_name}",
|
218
|
+
attachment_type=allure.attachment_type.TEXT
|
219
|
+
)
|
220
|
+
|
221
|
+
self.captured_values[var_name] = captured_value
|
222
|
+
except Exception as e:
|
223
|
+
error_msg = (
|
224
|
+
f"变量捕获失败: {var_name}\n"
|
225
|
+
f"捕获规格: {capture_spec}\n"
|
226
|
+
f"错误: {type(e).__name__}: {str(e)}"
|
227
|
+
)
|
228
|
+
allure.attach(
|
229
|
+
error_msg,
|
230
|
+
name=f"变量捕获失败: {var_name}",
|
231
|
+
attachment_type=allure.attachment_type.TEXT
|
232
|
+
)
|
233
|
+
# 设置默认值
|
234
|
+
self.captured_values[var_name] = None
|
235
|
+
|
236
|
+
return self.captured_values
|
237
|
+
|
238
|
+
def process_asserts(self, specific_asserts=None) -> List[Dict[str, Any]]:
|
239
|
+
"""处理响应断言
|
240
|
+
|
241
|
+
Args:
|
242
|
+
specific_asserts: 指定要处理的断言列表,如果为None则处理所有断言
|
243
|
+
|
244
|
+
Returns:
|
245
|
+
断言结果列表
|
246
|
+
"""
|
247
|
+
if not self.response:
|
248
|
+
raise ValueError("需要先执行请求才能进行断言")
|
249
|
+
|
250
|
+
asserts_config = self.config.get('asserts', [])
|
251
|
+
assert_results = []
|
252
|
+
failed_retryable_assertions = []
|
253
|
+
|
254
|
+
# 处理断言重试配置
|
255
|
+
# 1. 只使用独立的retry_assertions配置
|
256
|
+
retry_assertions_config = self.config.get('retry_assertions', {})
|
257
|
+
has_dedicated_retry_config = bool(retry_assertions_config)
|
258
|
+
|
259
|
+
# 2. 向后兼容: 检查全局retry配置(仅作为默认值使用)
|
260
|
+
retry_config = self.config.get('retry', {})
|
261
|
+
global_retry_enabled = bool(retry_config)
|
262
|
+
|
263
|
+
# 3. 提取重试默认设置
|
264
|
+
global_retry_count = retry_assertions_config.get('count', retry_config.get('count', 3))
|
265
|
+
global_retry_interval = retry_assertions_config.get('interval', retry_config.get('interval', 1))
|
266
|
+
|
267
|
+
# 4. 提取应该重试的断言索引列表
|
268
|
+
retry_all_assertions = retry_assertions_config.get('all', global_retry_enabled)
|
269
|
+
retry_assertion_indices = retry_assertions_config.get('indices', [])
|
270
|
+
|
271
|
+
# 5. 提取特定断言的重试配置
|
272
|
+
specific_assertion_configs = retry_assertions_config.get('specific', {})
|
273
|
+
|
274
|
+
# 如果传入了specific_asserts,只处理指定的断言
|
275
|
+
process_asserts = specific_asserts if specific_asserts is not None else asserts_config
|
276
|
+
|
277
|
+
for assertion_idx, assertion in enumerate(process_asserts):
|
278
|
+
if not isinstance(assertion, list) or len(assertion) < 2:
|
279
|
+
raise ValueError(f"无效的断言配置: {assertion}")
|
280
|
+
|
281
|
+
# 提取断言参数
|
282
|
+
extractor_type = assertion[0]
|
283
|
+
|
284
|
+
# 判断该断言是否应该重试(只使用retry_assertions配置)
|
285
|
+
is_retryable = False
|
286
|
+
assertion_retry_count = global_retry_count
|
287
|
+
assertion_retry_interval = global_retry_interval
|
288
|
+
|
289
|
+
# retry_assertions特定配置
|
290
|
+
if str(assertion_idx) in specific_assertion_configs:
|
291
|
+
spec_config = specific_assertion_configs[str(assertion_idx)]
|
292
|
+
is_retryable = True
|
293
|
+
if isinstance(spec_config, dict):
|
294
|
+
if 'count' in spec_config:
|
295
|
+
assertion_retry_count = spec_config['count']
|
296
|
+
if 'interval' in spec_config:
|
297
|
+
assertion_retry_interval = spec_config['interval']
|
298
|
+
# retry_assertions索引列表
|
299
|
+
elif assertion_idx in retry_assertion_indices:
|
300
|
+
is_retryable = True
|
301
|
+
# retry_assertions全局配置
|
302
|
+
elif retry_all_assertions:
|
303
|
+
is_retryable = True
|
304
|
+
|
305
|
+
# 处理断言参数
|
306
|
+
if len(assertion) == 2: # 存在性断言 ["header", "Location", "exists"]
|
307
|
+
extraction_path = assertion[1]
|
308
|
+
assertion_type = "exists"
|
309
|
+
expected_value = None
|
310
|
+
compare_operator = "eq" # 默认比较操作符
|
311
|
+
elif len(assertion) == 3: # 简单断言 ["status", "eq", 200]
|
312
|
+
if extractor_type in ["status", "body", "response_time"]:
|
313
|
+
extraction_path = None
|
314
|
+
assertion_type = "value" # 标记为简单值比较
|
315
|
+
compare_operator = assertion[1] # 比较操作符
|
316
|
+
expected_value = assertion[2] # 预期值
|
317
|
+
else:
|
318
|
+
extraction_path = assertion[1]
|
319
|
+
assertion_type = assertion[2]
|
320
|
+
compare_operator = "eq" # 默认比较操作符
|
321
|
+
expected_value = None
|
322
|
+
elif len(assertion) == 4: # 带操作符的断言 ["jsonpath", "$.id", "eq", 1]
|
323
|
+
extraction_path = assertion[1]
|
324
|
+
if assertion[2] in ["eq", "neq", "lt", "lte", "gt", "gte"]:
|
325
|
+
# 这是带比较操作符的断言
|
326
|
+
assertion_type = "value" # 标记为值比较
|
327
|
+
compare_operator = assertion[2] # 比较操作符
|
328
|
+
expected_value = assertion[3] # 预期值
|
329
|
+
else:
|
330
|
+
# 其他类型的断言,比如特殊断言
|
331
|
+
assertion_type = assertion[2]
|
332
|
+
compare_operator = "eq" # 默认比较操作符
|
333
|
+
expected_value = assertion[3]
|
334
|
+
else: # 5个参数,例如 ["jsonpath", "$", "length", "eq", 10]
|
335
|
+
extraction_path = assertion[1]
|
336
|
+
assertion_type = assertion[2]
|
337
|
+
compare_operator = assertion[3]
|
338
|
+
expected_value = assertion[4]
|
339
|
+
|
340
|
+
# 提取实际值
|
341
|
+
actual_value = self._extract_value(extractor_type, extraction_path)
|
342
|
+
|
343
|
+
# 特殊处理"length"断言类型
|
344
|
+
original_actual_value = actual_value
|
345
|
+
if assertion_type == "length" and extractor_type != "response_time" and extractor_type != "status" and extractor_type != "body":
|
346
|
+
try:
|
347
|
+
actual_value = len(actual_value)
|
348
|
+
except Exception as e:
|
349
|
+
# 长度计算失败的信息已在_extract_value中记录
|
350
|
+
actual_value = 0
|
351
|
+
|
352
|
+
# 执行断言
|
353
|
+
assertion_result = {
|
354
|
+
'type': extractor_type,
|
355
|
+
'path': extraction_path,
|
356
|
+
'assertion_type': assertion_type,
|
357
|
+
'operator': compare_operator,
|
358
|
+
'actual_value': actual_value,
|
359
|
+
'expected_value': expected_value,
|
360
|
+
'original_value': original_actual_value if assertion_type == "length" else None,
|
361
|
+
'retryable': is_retryable,
|
362
|
+
'retry_count': assertion_retry_count,
|
363
|
+
'retry_interval': assertion_retry_interval,
|
364
|
+
'index': assertion_idx # 记录断言在原始列表中的索引
|
365
|
+
}
|
366
|
+
|
367
|
+
try:
|
368
|
+
# 验证断言
|
369
|
+
result = self._perform_assertion(assertion_type, compare_operator, actual_value, expected_value)
|
370
|
+
assertion_result['result'] = result
|
371
|
+
assertion_result['passed'] = True
|
372
|
+
|
373
|
+
# 使用Allure记录断言成功
|
374
|
+
allure.attach(
|
375
|
+
self._format_assertion_details(assertion_result),
|
376
|
+
name=f"断言成功: {extractor_type}",
|
377
|
+
attachment_type=allure.attachment_type.TEXT
|
378
|
+
)
|
379
|
+
except AssertionError as e:
|
380
|
+
assertion_result['result'] = False
|
381
|
+
assertion_result['passed'] = False
|
382
|
+
assertion_result['error'] = str(e)
|
383
|
+
|
384
|
+
# 使用Allure记录断言失败
|
385
|
+
allure.attach(
|
386
|
+
self._format_assertion_details(assertion_result) + f"\n\n错误: {str(e)}",
|
387
|
+
name=f"断言失败: {extractor_type}",
|
388
|
+
attachment_type=allure.attachment_type.TEXT
|
389
|
+
)
|
390
|
+
|
391
|
+
# 如果断言可重试,添加到失败且需要重试的断言列表
|
392
|
+
if is_retryable:
|
393
|
+
failed_retryable_assertions.append(assertion_result)
|
394
|
+
|
395
|
+
# 抛出异常(会在外层捕获)
|
396
|
+
raise AssertionError(f"断言失败 [{extractor_type}]: {str(e)}")
|
397
|
+
|
398
|
+
assert_results.append(assertion_result)
|
399
|
+
|
400
|
+
# 返回断言结果和需要重试的断言
|
401
|
+
return assert_results, failed_retryable_assertions
|
402
|
+
|
403
|
+
def _format_assertion_details(self, assertion_result: Dict[str, Any]) -> str:
|
404
|
+
"""格式化断言详情,用于Allure报告
|
405
|
+
|
406
|
+
Args:
|
407
|
+
assertion_result: 断言结果字典
|
408
|
+
|
409
|
+
Returns:
|
410
|
+
格式化的断言详情字符串
|
411
|
+
"""
|
412
|
+
details = f"类型: {assertion_result['type']}\n"
|
413
|
+
if assertion_result['path']:
|
414
|
+
details += f"路径: {assertion_result['path']}\n"
|
415
|
+
|
416
|
+
if assertion_result['assertion_type'] == 'length':
|
417
|
+
details += f"原始值: {assertion_result['original_value']}\n"
|
418
|
+
details += f"长度: {assertion_result['actual_value']}\n"
|
419
|
+
else:
|
420
|
+
details += f"实际值: {assertion_result['actual_value']}\n"
|
421
|
+
|
422
|
+
details += f"操作符: {assertion_result['operator']}\n"
|
423
|
+
|
424
|
+
if assertion_result['expected_value'] is not None:
|
425
|
+
details += f"预期值: {assertion_result['expected_value']}\n"
|
426
|
+
|
427
|
+
details += f"结果: {'通过' if assertion_result['passed'] else '失败'}"
|
428
|
+
|
429
|
+
return details
|
430
|
+
|
431
|
+
def _extract_value(self, extractor_type: str, extraction_path: str = None, default_value: Any = None) -> Any:
|
432
|
+
"""从响应提取值
|
433
|
+
|
434
|
+
Args:
|
435
|
+
extractor_type: 提取器类型
|
436
|
+
extraction_path: 提取路径
|
437
|
+
default_value: 默认值
|
438
|
+
|
439
|
+
Returns:
|
440
|
+
提取的值
|
441
|
+
"""
|
442
|
+
if not self.response:
|
443
|
+
return default_value
|
444
|
+
|
445
|
+
try:
|
446
|
+
if extractor_type == "jsonpath":
|
447
|
+
return self._extract_jsonpath(extraction_path, default_value)
|
448
|
+
elif extractor_type == "xpath":
|
449
|
+
return self._extract_xpath(extraction_path, default_value)
|
450
|
+
elif extractor_type == "regex":
|
451
|
+
return self._extract_regex(extraction_path, default_value)
|
452
|
+
elif extractor_type == "header":
|
453
|
+
return self._extract_header(extraction_path, default_value)
|
454
|
+
elif extractor_type == "cookie":
|
455
|
+
return self._extract_cookie(extraction_path, default_value)
|
456
|
+
elif extractor_type == "status":
|
457
|
+
return self.response.status_code
|
458
|
+
elif extractor_type == "body":
|
459
|
+
return self.response.text
|
460
|
+
elif extractor_type == "response_time":
|
461
|
+
return self.response.elapsed.total_seconds() * 1000
|
462
|
+
else:
|
463
|
+
raise ValueError(f"不支持的提取器类型: {extractor_type}")
|
464
|
+
except Exception as e:
|
465
|
+
if default_value is not None:
|
466
|
+
return default_value
|
467
|
+
raise ValueError(f"提取值失败({extractor_type}, {extraction_path}): {str(e)}")
|
468
|
+
|
469
|
+
def _extract_jsonpath(self, path: str, default_value: Any = None) -> Any:
|
470
|
+
"""使用JSONPath从JSON响应提取值
|
471
|
+
|
472
|
+
Args:
|
473
|
+
path: JSONPath表达式
|
474
|
+
default_value: 默认值
|
475
|
+
|
476
|
+
Returns:
|
477
|
+
提取的值
|
478
|
+
"""
|
479
|
+
try:
|
480
|
+
json_data = self.response.json()
|
481
|
+
|
482
|
+
jsonpath_expr = jsonpath.parse(path)
|
483
|
+
matches = [match.value for match in jsonpath_expr.find(json_data)]
|
484
|
+
|
485
|
+
if not matches:
|
486
|
+
return default_value
|
487
|
+
elif len(matches) == 1:
|
488
|
+
return matches[0]
|
489
|
+
else:
|
490
|
+
return matches
|
491
|
+
except Exception as e:
|
492
|
+
if default_value is not None:
|
493
|
+
return default_value
|
494
|
+
raise ValueError(f"JSONPath提取失败: {str(e)}")
|
495
|
+
|
496
|
+
def _extract_xpath(self, path: str, default_value: Any = None) -> Any:
|
497
|
+
"""使用XPath从HTML/XML响应提取值
|
498
|
+
|
499
|
+
Args:
|
500
|
+
path: XPath表达式
|
501
|
+
default_value: 默认值
|
502
|
+
|
503
|
+
Returns:
|
504
|
+
提取的值
|
505
|
+
"""
|
506
|
+
try:
|
507
|
+
# 尝试解析响应内容
|
508
|
+
parser = etree.HTMLParser()
|
509
|
+
tree = etree.fromstring(self.response.content, parser)
|
510
|
+
|
511
|
+
# 执行XPath
|
512
|
+
result = tree.xpath(path)
|
513
|
+
|
514
|
+
if not result:
|
515
|
+
return default_value
|
516
|
+
elif len(result) == 1:
|
517
|
+
return result[0]
|
518
|
+
else:
|
519
|
+
return result
|
520
|
+
except Exception as e:
|
521
|
+
if default_value is not None:
|
522
|
+
return default_value
|
523
|
+
raise ValueError(f"XPath提取失败: {str(e)}")
|
524
|
+
|
525
|
+
def _extract_regex(self, pattern: str, default_value: Any = None) -> Any:
|
526
|
+
"""使用正则表达式从响应提取值
|
527
|
+
|
528
|
+
Args:
|
529
|
+
pattern: 正则表达式模式
|
530
|
+
default_value: 默认值
|
531
|
+
|
532
|
+
Returns:
|
533
|
+
提取的值
|
534
|
+
"""
|
535
|
+
try:
|
536
|
+
# 如果响应是JSON格式,先转换为字符串
|
537
|
+
if 'application/json' in self.response.headers.get('Content-Type', ''):
|
538
|
+
text = json.dumps(self.response.json())
|
539
|
+
else:
|
540
|
+
text = self.response.text
|
541
|
+
|
542
|
+
matches = re.findall(pattern, text)
|
543
|
+
|
544
|
+
if not matches:
|
545
|
+
return default_value
|
546
|
+
elif len(matches) == 1:
|
547
|
+
return matches[0]
|
548
|
+
else:
|
549
|
+
return matches
|
550
|
+
except Exception as e:
|
551
|
+
if default_value is not None:
|
552
|
+
return default_value
|
553
|
+
raise ValueError(f"正则表达式提取失败: {str(e)}")
|
554
|
+
|
555
|
+
def _extract_header(self, header_name: str, default_value: Any = None) -> Any:
|
556
|
+
"""从响应头提取值
|
557
|
+
|
558
|
+
Args:
|
559
|
+
header_name: 响应头名称
|
560
|
+
default_value: 默认值
|
561
|
+
|
562
|
+
Returns:
|
563
|
+
提取的值
|
564
|
+
"""
|
565
|
+
header_value = self.response.headers.get(header_name)
|
566
|
+
return header_value if header_value is not None else default_value
|
567
|
+
|
568
|
+
def _extract_cookie(self, cookie_name: str, default_value: Any = None) -> Any:
|
569
|
+
"""从响应Cookie提取值
|
570
|
+
|
571
|
+
Args:
|
572
|
+
cookie_name: Cookie名称
|
573
|
+
default_value: 默认值
|
574
|
+
|
575
|
+
Returns:
|
576
|
+
提取的值
|
577
|
+
"""
|
578
|
+
cookie = self.response.cookies.get(cookie_name)
|
579
|
+
return cookie if cookie is not None else default_value
|
580
|
+
|
581
|
+
def _perform_assertion(self, assertion_type: str, operator: str, actual_value: Any, expected_value: Any = None) -> bool:
|
582
|
+
"""执行断言
|
583
|
+
|
584
|
+
Args:
|
585
|
+
assertion_type: 断言类型
|
586
|
+
operator: 比较操作符
|
587
|
+
actual_value: 实际值
|
588
|
+
expected_value: 预期值
|
589
|
+
|
590
|
+
Returns:
|
591
|
+
断言结果
|
592
|
+
"""
|
593
|
+
# 类型转换
|
594
|
+
if operator in ["eq", "neq", "lt", "lte", "gt", "gte"] and expected_value is not None:
|
595
|
+
if isinstance(expected_value, str):
|
596
|
+
# 去除空白字符和换行符后再判断
|
597
|
+
clean_expected = expected_value.strip()
|
598
|
+
if clean_expected.isdigit():
|
599
|
+
expected_value = int(clean_expected)
|
600
|
+
elif clean_expected.replace('.', '', 1).isdigit():
|
601
|
+
expected_value = float(clean_expected)
|
602
|
+
|
603
|
+
if isinstance(actual_value, str):
|
604
|
+
# 去除空白字符和换行符后再判断
|
605
|
+
clean_actual = actual_value.strip()
|
606
|
+
if clean_actual.isdigit():
|
607
|
+
actual_value = int(clean_actual)
|
608
|
+
elif clean_actual.replace('.', '', 1).isdigit():
|
609
|
+
actual_value = float(clean_actual)
|
610
|
+
|
611
|
+
# 基于断言类型执行断言
|
612
|
+
if assertion_type == "value" or assertion_type == "length":
|
613
|
+
# 直接使用操作符进行比较
|
614
|
+
return self._compare_values(actual_value, expected_value, operator)
|
615
|
+
elif assertion_type == "exists":
|
616
|
+
return actual_value is not None
|
617
|
+
elif assertion_type == "not_exists":
|
618
|
+
return actual_value is None
|
619
|
+
elif assertion_type == "type":
|
620
|
+
if expected_value == "string":
|
621
|
+
return isinstance(actual_value, str)
|
622
|
+
elif expected_value == "number":
|
623
|
+
return isinstance(actual_value, (int, float))
|
624
|
+
elif expected_value == "boolean":
|
625
|
+
return isinstance(actual_value, bool)
|
626
|
+
elif expected_value == "array":
|
627
|
+
return isinstance(actual_value, list)
|
628
|
+
elif expected_value == "object":
|
629
|
+
return isinstance(actual_value, dict)
|
630
|
+
elif expected_value == "null":
|
631
|
+
return actual_value is None
|
632
|
+
return False
|
633
|
+
elif assertion_type == "contains":
|
634
|
+
if isinstance(actual_value, str) and isinstance(expected_value, str):
|
635
|
+
return expected_value in actual_value
|
636
|
+
elif isinstance(actual_value, (list, tuple, dict)):
|
637
|
+
return expected_value in actual_value
|
638
|
+
return False
|
639
|
+
elif assertion_type == "startswith":
|
640
|
+
return isinstance(actual_value, str) and actual_value.startswith(expected_value)
|
641
|
+
elif assertion_type == "endswith":
|
642
|
+
return isinstance(actual_value, str) and actual_value.endswith(expected_value)
|
643
|
+
elif assertion_type == "matches":
|
644
|
+
if not isinstance(actual_value, str) or not isinstance(expected_value, str):
|
645
|
+
return False
|
646
|
+
try:
|
647
|
+
import re
|
648
|
+
return bool(re.search(expected_value, actual_value))
|
649
|
+
except:
|
650
|
+
return False
|
651
|
+
elif assertion_type == "in":
|
652
|
+
return actual_value in expected_value
|
653
|
+
elif assertion_type == "not_in":
|
654
|
+
return actual_value not in expected_value
|
655
|
+
elif assertion_type == "schema":
|
656
|
+
try:
|
657
|
+
from jsonschema import validate
|
658
|
+
validate(instance=actual_value, schema=expected_value)
|
659
|
+
return True
|
660
|
+
except:
|
661
|
+
return False
|
662
|
+
else:
|
663
|
+
raise ValueError(f"不支持的断言类型: {assertion_type}")
|
664
|
+
|
665
|
+
def _compare_values(self, actual_value: Any, expected_value: Any, operator: str) -> bool:
|
666
|
+
"""比较两个值
|
667
|
+
|
668
|
+
Args:
|
669
|
+
actual_value: 实际值
|
670
|
+
expected_value: 预期值
|
671
|
+
operator: 比较操作符
|
672
|
+
|
673
|
+
Returns:
|
674
|
+
比较结果
|
675
|
+
"""
|
676
|
+
if operator == "eq":
|
677
|
+
return actual_value == expected_value
|
678
|
+
elif operator == "neq":
|
679
|
+
return actual_value != expected_value
|
680
|
+
elif operator == "lt":
|
681
|
+
return actual_value < expected_value
|
682
|
+
elif operator == "lte":
|
683
|
+
return actual_value <= expected_value
|
684
|
+
elif operator == "gt":
|
685
|
+
return actual_value > expected_value
|
686
|
+
elif operator == "gte":
|
687
|
+
return actual_value >= expected_value
|
688
|
+
elif operator == "in":
|
689
|
+
return actual_value in expected_value
|
690
|
+
elif operator == "not_in":
|
691
|
+
return actual_value not in expected_value
|
692
|
+
elif operator == "contains":
|
693
|
+
if isinstance(actual_value, str) and isinstance(expected_value, str):
|
694
|
+
return expected_value in actual_value
|
695
|
+
elif isinstance(actual_value, (list, tuple, dict)):
|
696
|
+
return expected_value in actual_value
|
697
|
+
return False
|
698
|
+
elif operator == "not_contains":
|
699
|
+
if isinstance(actual_value, str) and isinstance(expected_value, str):
|
700
|
+
return expected_value not in actual_value
|
701
|
+
elif isinstance(actual_value, (list, tuple, dict)):
|
702
|
+
return expected_value not in actual_value
|
703
|
+
return True
|
704
|
+
elif operator == "matches":
|
705
|
+
if not isinstance(actual_value, str) or not isinstance(expected_value, str):
|
706
|
+
return False
|
707
|
+
try:
|
708
|
+
import re
|
709
|
+
return bool(re.search(expected_value, actual_value))
|
710
|
+
except:
|
711
|
+
return False
|
712
|
+
else:
|
713
|
+
raise ValueError(f"不支持的比较操作符: {operator}")
|
714
|
+
|
715
|
+
def _log_request_to_allure(self, method: str, url: str, request_kwargs: Dict[str, Any]) -> None:
|
716
|
+
"""使用Allure记录请求信息
|
717
|
+
|
718
|
+
Args:
|
719
|
+
method: HTTP方法
|
720
|
+
url: 请求URL
|
721
|
+
request_kwargs: 请求参数
|
722
|
+
"""
|
723
|
+
# 创建请求信息摘要
|
724
|
+
request_summary = f"{method} {url}"
|
725
|
+
|
726
|
+
# 创建详细请求信息
|
727
|
+
request_details = [f"Method: {method}", f"URL: {url}"]
|
728
|
+
|
729
|
+
# 添加请求头
|
730
|
+
if "headers" in request_kwargs and request_kwargs["headers"]:
|
731
|
+
# 隐藏敏感信息
|
732
|
+
safe_headers = {}
|
733
|
+
for key, value in request_kwargs["headers"].items():
|
734
|
+
if key.lower() in ["authorization", "x-api-key", "token", "api-key"]:
|
735
|
+
safe_headers[key] = "***REDACTED***"
|
736
|
+
else:
|
737
|
+
safe_headers[key] = value
|
738
|
+
request_details.append("Headers:")
|
739
|
+
for key, value in safe_headers.items():
|
740
|
+
request_details.append(f" {key}: {value}")
|
741
|
+
|
742
|
+
# 添加查询参数
|
743
|
+
if "params" in request_kwargs and request_kwargs["params"]:
|
744
|
+
request_details.append("Query Parameters:")
|
745
|
+
for key, value in request_kwargs["params"].items():
|
746
|
+
request_details.append(f" {key}: {value}")
|
747
|
+
|
748
|
+
# 添加请求体
|
749
|
+
if "json" in request_kwargs and request_kwargs["json"]:
|
750
|
+
request_details.append("JSON Body:")
|
751
|
+
try:
|
752
|
+
request_details.append(json.dumps(request_kwargs["json"], indent=2, ensure_ascii=False))
|
753
|
+
except:
|
754
|
+
request_details.append(str(request_kwargs["json"]))
|
755
|
+
elif "data" in request_kwargs and request_kwargs["data"]:
|
756
|
+
request_details.append("Form Data:")
|
757
|
+
for key, value in request_kwargs["data"].items():
|
758
|
+
request_details.append(f" {key}: {value}")
|
759
|
+
|
760
|
+
# 添加文件信息
|
761
|
+
if "files" in request_kwargs and request_kwargs["files"]:
|
762
|
+
request_details.append("Files:")
|
763
|
+
for key, value in request_kwargs["files"].items():
|
764
|
+
request_details.append(f" {key}: <File object>")
|
765
|
+
|
766
|
+
# 记录到Allure
|
767
|
+
allure.attach(
|
768
|
+
"\n".join(request_details),
|
769
|
+
name=f"HTTP请求: {request_summary}",
|
770
|
+
attachment_type=allure.attachment_type.TEXT
|
771
|
+
)
|
772
|
+
|
773
|
+
def _log_response_to_allure(self, response: Response) -> None:
|
774
|
+
"""使用Allure记录响应信息
|
775
|
+
|
776
|
+
Args:
|
777
|
+
response: 响应对象
|
778
|
+
"""
|
779
|
+
# 创建响应信息摘要
|
780
|
+
response_summary = f"{response.status_code} {response.reason} ({response.elapsed.total_seconds() * 1000:.2f}ms)"
|
781
|
+
|
782
|
+
# 创建详细响应信息
|
783
|
+
response_details = [
|
784
|
+
f"Status: {response.status_code} {response.reason}",
|
785
|
+
f"Response Time: {response.elapsed.total_seconds() * 1000:.2f}ms"
|
786
|
+
]
|
787
|
+
|
788
|
+
# 添加响应头
|
789
|
+
response_details.append("Headers:")
|
790
|
+
for key, value in response.headers.items():
|
791
|
+
response_details.append(f" {key}: {value}")
|
792
|
+
|
793
|
+
# 添加响应体
|
794
|
+
response_details.append("Body:")
|
795
|
+
try:
|
796
|
+
if 'application/json' in response.headers.get('Content-Type', ''):
|
797
|
+
response_details.append(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
798
|
+
elif len(response.content) < 10240: # 限制大小
|
799
|
+
response_details.append(response.text)
|
800
|
+
else:
|
801
|
+
response_details.append(f"<{len(response.content)} bytes>")
|
802
|
+
except Exception as e:
|
803
|
+
response_details.append(f"<Error parsing body: {str(e)}>")
|
804
|
+
|
805
|
+
# 记录到Allure
|
806
|
+
allure.attach(
|
807
|
+
"\n".join(response_details),
|
808
|
+
name=f"HTTP响应: {response_summary}",
|
809
|
+
attachment_type=allure.attachment_type.TEXT
|
810
|
+
)
|