iflow-mcp_galaxyxieyu_api-auto-test 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.
- atf/__init__.py +48 -0
- atf/assets/__init__.py +0 -0
- atf/assets/report.css +243 -0
- atf/auth.py +99 -0
- atf/case_generator.py +737 -0
- atf/conftest.py +65 -0
- atf/core/__init__.py +40 -0
- atf/core/assert_handler.py +336 -0
- atf/core/config_manager.py +111 -0
- atf/core/globals.py +52 -0
- atf/core/log_manager.py +52 -0
- atf/core/login_handler.py +60 -0
- atf/core/request_handler.py +189 -0
- atf/core/variable_resolver.py +212 -0
- atf/handlers/__init__.py +10 -0
- atf/handlers/notification_handler.py +101 -0
- atf/handlers/report_generator.py +160 -0
- atf/handlers/teardown_handler.py +106 -0
- atf/mcp/__init__.py +1 -0
- atf/mcp/executor.py +469 -0
- atf/mcp/models.py +532 -0
- atf/mcp/tools/__init__.py +1 -0
- atf/mcp/tools/health_tool.py +58 -0
- atf/mcp/tools/metrics_tools.py +132 -0
- atf/mcp/tools/runner_tools.py +380 -0
- atf/mcp/tools/testcase_tools.py +603 -0
- atf/mcp/tools/unittest_tools.py +189 -0
- atf/mcp/utils.py +376 -0
- atf/mcp_server.py +169 -0
- atf/runner.py +134 -0
- atf/unit_case_generator.py +337 -0
- atf/utils/__init__.py +2 -0
- atf/utils/helpers.py +155 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/METADATA +409 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/RECORD +37 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/WHEEL +4 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/entry_points.txt +2 -0
atf/conftest.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @time: 2024-09-10
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from atf.core.globals import Globals
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ==================== pytest-html 报告配置 ====================
|
|
11
|
+
|
|
12
|
+
def pytest_configure(config):
|
|
13
|
+
"""配置 pytest-html 元数据"""
|
|
14
|
+
config._metadata = getattr(config, '_metadata', {}) or {}
|
|
15
|
+
config._metadata["项目"] = "API Auto Test Framework"
|
|
16
|
+
config._metadata["框架"] = "pytest + pytest-html"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_html_report_title(report):
|
|
20
|
+
"""设置报告标题"""
|
|
21
|
+
report.title = "API 自动化测试报告"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
25
|
+
def pytest_runtest_makereport(item, call):
|
|
26
|
+
"""为测试结果添加描述信息"""
|
|
27
|
+
outcome = yield
|
|
28
|
+
report = outcome.get_result()
|
|
29
|
+
report.description = str(item.function.__doc__ or "")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ==================== 测试结果收集 ====================
|
|
33
|
+
|
|
34
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
35
|
+
"""
|
|
36
|
+
获取用例执行结果,保存到全局变量
|
|
37
|
+
"""
|
|
38
|
+
total = terminalreporter._numcollected
|
|
39
|
+
passed = len([i for i in terminalreporter.stats.get("passed", []) if i.when != "teardown"])
|
|
40
|
+
failed = len([i for i in terminalreporter.stats.get("failed", []) if i.when != "teardown"])
|
|
41
|
+
error = len([i for i in terminalreporter.stats.get("error", []) if i.when != "teardown"])
|
|
42
|
+
skipped = len([i for i in terminalreporter.stats.get("skipped", []) if i.when != "teardown"])
|
|
43
|
+
_start_time = terminalreporter._sessionstarttime
|
|
44
|
+
start_time = datetime.utcfromtimestamp(_start_time).strftime("%Y-%m-%d %H:%M:%S")
|
|
45
|
+
duration = time.time() - _start_time
|
|
46
|
+
|
|
47
|
+
conclusion = "执行通过"
|
|
48
|
+
if failed and error:
|
|
49
|
+
conclusion = "执行失败,包含失败用例和错误用例!"
|
|
50
|
+
elif failed and not error:
|
|
51
|
+
conclusion = "执行失败,包含失败用例!"
|
|
52
|
+
elif not failed and error:
|
|
53
|
+
conclusion = "执行失败,包含报错用例!"
|
|
54
|
+
|
|
55
|
+
results = {
|
|
56
|
+
"conclusion": conclusion,
|
|
57
|
+
"total": total,
|
|
58
|
+
"passed": passed,
|
|
59
|
+
"failed": failed,
|
|
60
|
+
"error": error,
|
|
61
|
+
"skipped": skipped,
|
|
62
|
+
"start_time": start_time,
|
|
63
|
+
"duration": duration
|
|
64
|
+
}
|
|
65
|
+
Globals.set('test_results', results)
|
atf/core/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""核心模块
|
|
2
|
+
|
|
3
|
+
使用延迟导入,避免在导入 atf.core 包时加载所有依赖。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Globals",
|
|
10
|
+
"ConfigManager",
|
|
11
|
+
"LogManager",
|
|
12
|
+
"RequestHandler",
|
|
13
|
+
"VariableResolver",
|
|
14
|
+
"AssertHandler",
|
|
15
|
+
"LoginHandler",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_LAZY_IMPORTS = {
|
|
19
|
+
"Globals": "atf.core.globals",
|
|
20
|
+
"ConfigManager": "atf.core.config_manager",
|
|
21
|
+
"LogManager": "atf.core.log_manager",
|
|
22
|
+
"RequestHandler": "atf.core.request_handler",
|
|
23
|
+
"VariableResolver": "atf.core.variable_resolver",
|
|
24
|
+
"AssertHandler": "atf.core.assert_handler",
|
|
25
|
+
"LoginHandler": "atf.core.login_handler",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name):
|
|
30
|
+
module_path = _LAZY_IMPORTS.get(name)
|
|
31
|
+
if not module_path:
|
|
32
|
+
raise AttributeError(f"module 'atf.core' has no attribute '{name}'")
|
|
33
|
+
module = import_module(module_path)
|
|
34
|
+
value = getattr(module, name)
|
|
35
|
+
globals()[name] = value
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __dir__():
|
|
40
|
+
return sorted(list(globals().keys()) + list(_LAZY_IMPORTS.keys()))
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# @time: 2024-08-06
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import mysql.connector
|
|
6
|
+
import threading
|
|
7
|
+
from atf.core.log_manager import log
|
|
8
|
+
|
|
9
|
+
class AssertHandler:
|
|
10
|
+
"""断言处理器"""
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.connection = None
|
|
13
|
+
self.lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
def __enter__(self):
|
|
16
|
+
log.info("Opening database connection")
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
20
|
+
if self.connection:
|
|
21
|
+
log.info("Closing database connection")
|
|
22
|
+
self.connection.close()
|
|
23
|
+
|
|
24
|
+
def handle_assertion(self, asserts, response, db_config=None):
|
|
25
|
+
"""
|
|
26
|
+
验证 assert 列表中的所有断言
|
|
27
|
+
:param asserts: 断言列表
|
|
28
|
+
:param response: 请求返回的 response 对象
|
|
29
|
+
:param db_config: 数据库配置字典,包含 host, user, password, database 等信息
|
|
30
|
+
"""
|
|
31
|
+
log.info(f"执行断言: {asserts}")
|
|
32
|
+
# 如果存在 MySQL 相关的断言类型,建立数据库连接
|
|
33
|
+
if any(assertion['type'].startswith('mysql') for assertion in asserts):
|
|
34
|
+
if db_config is None:
|
|
35
|
+
raise ValueError("数据库配置 db_config 不能为空,因为有 MySQL 相关断言")
|
|
36
|
+
self.connection = mysql.connector.connect(**db_config)
|
|
37
|
+
|
|
38
|
+
for assertion in asserts:
|
|
39
|
+
assert_type = assertion.get('type')
|
|
40
|
+
field_path = assertion.get('field')
|
|
41
|
+
expected = assertion.get('expected')
|
|
42
|
+
container = assertion.get('container')
|
|
43
|
+
query = assertion.get('query')
|
|
44
|
+
|
|
45
|
+
# 检查必填字段
|
|
46
|
+
if not field_path and assert_type not in [
|
|
47
|
+
'mysql_query',
|
|
48
|
+
'mysql_query_exists',
|
|
49
|
+
'mysql_query_true',
|
|
50
|
+
'contains',
|
|
51
|
+
'contain',
|
|
52
|
+
'status_code',
|
|
53
|
+
'status',
|
|
54
|
+
]:
|
|
55
|
+
log.error(f"断言的 'field' 不能为空: {assertion}")
|
|
56
|
+
raise ValueError(f"断言的 'field' 不能为空: {assertion}")
|
|
57
|
+
if assert_type in ['equal', 'equals', 'not equal', 'not_equal', 'not_equals'] and expected is None:
|
|
58
|
+
log.error(f"断言的 'expected' 不能为空: {assertion}")
|
|
59
|
+
raise ValueError(f"断言的 'expected' 不能为空: {assertion}")
|
|
60
|
+
if assert_type == 'length' and expected is None:
|
|
61
|
+
log.error(f"断言的 'expected' 不能为空: {assertion}")
|
|
62
|
+
raise ValueError(f"断言的 'expected' 不能为空: {assertion}")
|
|
63
|
+
if assert_type in ['in', 'not in', 'not_in'] and container is None:
|
|
64
|
+
log.error(f"断言的 'container' 不能为空: {assertion}")
|
|
65
|
+
raise ValueError(f"断言的 'container' 不能为空: {assertion}")
|
|
66
|
+
|
|
67
|
+
# 获取字段的实际值
|
|
68
|
+
field_value = self.get_field_value(response, field_path) if field_path else None
|
|
69
|
+
|
|
70
|
+
# 处理各种断言类型
|
|
71
|
+
if assert_type in ('status_code', 'status'):
|
|
72
|
+
# status_code 检查 HTTP 状态码(response 可能是 dict 或 Response 对象)
|
|
73
|
+
actual_status = response.get('_status_code') if isinstance(response, dict) else response.status_code
|
|
74
|
+
assert actual_status == expected, f"Expected status code {expected}, but got {actual_status}"
|
|
75
|
+
elif assert_type in ('equal', 'equals'):
|
|
76
|
+
assert field_value == expected, f"Expected {expected}, but got {field_value}"
|
|
77
|
+
elif assert_type in ('not equal', 'not_equal', 'not_equals'):
|
|
78
|
+
assert field_value != expected, f"Expected not {expected}, but got {field_value}"
|
|
79
|
+
elif assert_type in ('exists', 'exist'):
|
|
80
|
+
assert field_value is not None, f"Expected field {field_path} to exist, but got None"
|
|
81
|
+
elif assert_type in ('is_none', 'is None', 'None'):
|
|
82
|
+
assert field_value is None, f"Expected None, but got {field_value}"
|
|
83
|
+
elif assert_type in ('is_not_none', 'is not None', 'not None'):
|
|
84
|
+
assert field_value is not None, f"Expected not None, but got {field_value}"
|
|
85
|
+
elif assert_type == 'length':
|
|
86
|
+
assert field_value is not None, f"Expected field {field_path} not to be None for length assertion"
|
|
87
|
+
try:
|
|
88
|
+
actual_len = len(field_value)
|
|
89
|
+
except TypeError:
|
|
90
|
+
raise ValueError(f"length 断言不支持该类型: field={field_path}, value={field_value}")
|
|
91
|
+
assert actual_len == expected, f"Expected length {expected}, but got {actual_len}"
|
|
92
|
+
elif assert_type == 'in':
|
|
93
|
+
assert field_value in container, f"Expected {field_value} to be in {container}"
|
|
94
|
+
elif assert_type in ('not in', 'not_in'):
|
|
95
|
+
assert field_value not in container, f"Expected {field_value} to be not in {container}"
|
|
96
|
+
elif assert_type in ('contains', 'contain'):
|
|
97
|
+
# contains 不需要 field,直接检查 response 中是否包含 expected
|
|
98
|
+
def _check_contains(obj, target):
|
|
99
|
+
"""递归检查 obj 中是否包含 target"""
|
|
100
|
+
if isinstance(obj, dict):
|
|
101
|
+
return any(_check_contains(v, target) for v in obj.values())
|
|
102
|
+
elif isinstance(obj, list):
|
|
103
|
+
return any(_check_contains(item, target) for item in obj)
|
|
104
|
+
elif isinstance(obj, str):
|
|
105
|
+
return target in obj
|
|
106
|
+
else:
|
|
107
|
+
return str(obj) == str(target)
|
|
108
|
+
|
|
109
|
+
assert _check_contains(response, expected), f"Expected {expected} to be in {response}"
|
|
110
|
+
elif assert_type == 'mysql_query':
|
|
111
|
+
self._validate_mysql_query(query, expected)
|
|
112
|
+
elif assert_type == 'mysql_query_exists':
|
|
113
|
+
self._validate_mysql_query_exists(query)
|
|
114
|
+
elif assert_type == 'mysql_query_true':
|
|
115
|
+
self._validate_mysql_query_true(query)
|
|
116
|
+
# SSE 断言类型
|
|
117
|
+
elif assert_type == 'sse_event_count':
|
|
118
|
+
self._validate_sse_event_count(response, assertion)
|
|
119
|
+
elif assert_type == 'sse_contains':
|
|
120
|
+
self._validate_sse_contains(response, assertion)
|
|
121
|
+
elif assert_type == 'sse_event_exists':
|
|
122
|
+
self._validate_sse_event_exists(response, assertion)
|
|
123
|
+
elif assert_type == 'sse_event_field':
|
|
124
|
+
self._validate_sse_event_field(response, assertion)
|
|
125
|
+
elif assert_type == 'sse_last_event':
|
|
126
|
+
self._validate_sse_last_event(response, assertion)
|
|
127
|
+
else:
|
|
128
|
+
raise ValueError(f"未知的断言类型: {assert_type}")
|
|
129
|
+
|
|
130
|
+
log.info(f"断言通过")
|
|
131
|
+
|
|
132
|
+
def get_field_value(self, response, field_path):
|
|
133
|
+
"""
|
|
134
|
+
根据字段路径获取响应中的值,支持数组索引和多级嵌套。
|
|
135
|
+
:param response: 响应数据
|
|
136
|
+
:param field_path: 字段路径,如 data[0].areaName.ids[1].id
|
|
137
|
+
:return: 字段值或 None
|
|
138
|
+
"""
|
|
139
|
+
# 使用正则表达式区分数组索引和字段名
|
|
140
|
+
pattern = re.compile(r'([a-zA-Z_][\w]*)\[(\d+)\]|([a-zA-Z_][\w]*)')
|
|
141
|
+
value = response
|
|
142
|
+
|
|
143
|
+
# 找到所有匹配的部分并逐一处理
|
|
144
|
+
for match in pattern.finditer(field_path):
|
|
145
|
+
field, index, simple_field = match.groups()
|
|
146
|
+
|
|
147
|
+
# 如果是数组形式,例如 data[0]
|
|
148
|
+
if field and index is not None:
|
|
149
|
+
value = value.get(field, []) if isinstance(value, dict) else None
|
|
150
|
+
if isinstance(value, list) and len(value) > int(index):
|
|
151
|
+
value = value[int(index)]
|
|
152
|
+
else:
|
|
153
|
+
value = None
|
|
154
|
+
|
|
155
|
+
# 如果是普通字段
|
|
156
|
+
elif simple_field:
|
|
157
|
+
value = value.get(simple_field) if isinstance(value, dict) else None
|
|
158
|
+
|
|
159
|
+
if value is None:
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_mysql_query(self, query, expected):
|
|
166
|
+
"""
|
|
167
|
+
执行 MySQL 查询并验证结果是否等于 expected
|
|
168
|
+
:param query: 要执行的 MySQL 查询
|
|
169
|
+
:param expected: 期望值
|
|
170
|
+
"""
|
|
171
|
+
result = self._execute_query(query)
|
|
172
|
+
assert result == expected, f"Expected {expected}, but got {result}"
|
|
173
|
+
|
|
174
|
+
def _validate_mysql_query_exists(self, query):
|
|
175
|
+
"""
|
|
176
|
+
执行 MySQL 查询并验证是否返回了至少一行结果
|
|
177
|
+
:param query: 要执行的 MySQL 查询
|
|
178
|
+
"""
|
|
179
|
+
result = self._execute_query(query)
|
|
180
|
+
assert result is not None, f"Expected query to return results, but got None"
|
|
181
|
+
|
|
182
|
+
def _validate_mysql_query_true(self, query):
|
|
183
|
+
"""
|
|
184
|
+
执行 MySQL 查询并验证结果是否为真 (存在)
|
|
185
|
+
:param query: 要执行的 MySQL 查询
|
|
186
|
+
"""
|
|
187
|
+
result = self._execute_query(query)
|
|
188
|
+
assert bool(result), f"Expected query result to be True, but got {result}"
|
|
189
|
+
|
|
190
|
+
def _execute_query(self, query):
|
|
191
|
+
"""
|
|
192
|
+
执行 MySQL 查询并返回结果
|
|
193
|
+
:param query: 要执行的 MySQL 查询
|
|
194
|
+
:return: 查询结果
|
|
195
|
+
"""
|
|
196
|
+
with self.lock:
|
|
197
|
+
cursor = self.connection.cursor()
|
|
198
|
+
cursor.execute(query)
|
|
199
|
+
try:
|
|
200
|
+
result = cursor.fetchone()
|
|
201
|
+
log.info(f"MySQL query executed: {query}, Result: {result}")
|
|
202
|
+
except mysql.connector.Error as err:
|
|
203
|
+
log.error(f"Error executing query: {query}, Error: {err}")
|
|
204
|
+
raise
|
|
205
|
+
cursor.close()
|
|
206
|
+
return result[0] if result else None
|
|
207
|
+
|
|
208
|
+
# ==================== SSE 断言方法 ====================
|
|
209
|
+
|
|
210
|
+
def _validate_sse_event_count(self, response, assertion):
|
|
211
|
+
"""
|
|
212
|
+
验证 SSE 事件数量
|
|
213
|
+
:param response: SSEResponse 对象
|
|
214
|
+
:param assertion: 断言配置,支持 expected (精确), min, max
|
|
215
|
+
"""
|
|
216
|
+
count = response.event_count
|
|
217
|
+
expected = assertion.get('expected')
|
|
218
|
+
min_count = assertion.get('min')
|
|
219
|
+
max_count = assertion.get('max')
|
|
220
|
+
|
|
221
|
+
if expected is not None:
|
|
222
|
+
assert count == expected, f"SSE 事件数量期望 {expected},实际 {count}"
|
|
223
|
+
if min_count is not None:
|
|
224
|
+
assert count >= min_count, f"SSE 事件数量至少 {min_count},实际 {count}"
|
|
225
|
+
if max_count is not None:
|
|
226
|
+
assert count <= max_count, f"SSE 事件数量最多 {max_count},实际 {count}"
|
|
227
|
+
log.info(f"SSE 事件数量断言通过: {count}")
|
|
228
|
+
|
|
229
|
+
def _validate_sse_contains(self, response, assertion):
|
|
230
|
+
"""
|
|
231
|
+
验证 SSE 事件中是否包含指定文本
|
|
232
|
+
:param response: SSEResponse 对象
|
|
233
|
+
:param assertion: 断言配置,expected 为要查找的文本
|
|
234
|
+
"""
|
|
235
|
+
expected = assertion.get('expected')
|
|
236
|
+
assert response.contains(expected), f"SSE 事件中未找到文本: {expected}"
|
|
237
|
+
log.info(f"SSE 包含断言通过: {expected}")
|
|
238
|
+
|
|
239
|
+
def _validate_sse_event_exists(self, response, assertion):
|
|
240
|
+
"""
|
|
241
|
+
验证是否存在满足条件的 SSE 事件
|
|
242
|
+
:param response: SSEResponse 对象
|
|
243
|
+
:param assertion: 断言配置,支持 event_type, data_contains 等条件
|
|
244
|
+
"""
|
|
245
|
+
conditions = {}
|
|
246
|
+
if 'event_type' in assertion:
|
|
247
|
+
conditions['event_type'] = assertion['event_type']
|
|
248
|
+
if 'data_contains' in assertion:
|
|
249
|
+
conditions['data_contains'] = assertion['data_contains']
|
|
250
|
+
|
|
251
|
+
event = response.find_event(**conditions)
|
|
252
|
+
assert event is not None, f"未找到满足条件的 SSE 事件: {conditions}"
|
|
253
|
+
log.info(f"SSE 事件存在断言通过: {conditions}")
|
|
254
|
+
|
|
255
|
+
def _validate_sse_event_field(self, response, assertion):
|
|
256
|
+
"""
|
|
257
|
+
验证指定索引的 SSE 事件的字段值
|
|
258
|
+
:param response: SSEResponse 对象
|
|
259
|
+
:param assertion: 断言配置,index 为事件索引,field 为字段路径,expected 为期望值
|
|
260
|
+
"""
|
|
261
|
+
index = assertion.get('index', 0)
|
|
262
|
+
field = assertion.get('field')
|
|
263
|
+
expected = assertion.get('expected')
|
|
264
|
+
|
|
265
|
+
event = response.get_event(index)
|
|
266
|
+
assert event is not None, f"SSE 事件索引 {index} 不存在"
|
|
267
|
+
|
|
268
|
+
# 从 event.data 中获取字段值
|
|
269
|
+
value = self._get_nested_value(event.get('data', {}), field)
|
|
270
|
+
assert value == expected, f"SSE 事件[{index}].{field} 期望 {expected},实际 {value}"
|
|
271
|
+
log.info(f"SSE 事件字段断言通过: [{index}].{field} = {expected}")
|
|
272
|
+
|
|
273
|
+
def _validate_sse_last_event(self, response, assertion):
|
|
274
|
+
"""
|
|
275
|
+
验证最后一个 SSE 事件
|
|
276
|
+
:param response: SSEResponse 对象
|
|
277
|
+
:param assertion: 断言配置,支持 event_type, data_contains, field + expected
|
|
278
|
+
"""
|
|
279
|
+
event = response.get_event(-1)
|
|
280
|
+
assert event is not None, "没有收到任何 SSE 事件"
|
|
281
|
+
|
|
282
|
+
if 'event_type' in assertion:
|
|
283
|
+
assert event.get('event') == assertion['event_type'], \
|
|
284
|
+
f"最后事件类型期望 {assertion['event_type']},实际 {event.get('event')}"
|
|
285
|
+
|
|
286
|
+
if 'data_contains' in assertion:
|
|
287
|
+
assert assertion['data_contains'] in str(event.get('data', '')), \
|
|
288
|
+
f"最后事件未包含: {assertion['data_contains']}"
|
|
289
|
+
|
|
290
|
+
if 'field' in assertion and 'expected' in assertion:
|
|
291
|
+
value = self._get_nested_value(event.get('data', {}), assertion['field'])
|
|
292
|
+
assert value == assertion['expected'], \
|
|
293
|
+
f"最后事件 {assertion['field']} 期望 {assertion['expected']},实际 {value}"
|
|
294
|
+
|
|
295
|
+
log.info(f"SSE 最后事件断言通过")
|
|
296
|
+
|
|
297
|
+
def _get_nested_value(self, data, field_path):
|
|
298
|
+
"""从嵌套字典中获取值"""
|
|
299
|
+
if not field_path or not isinstance(data, dict):
|
|
300
|
+
return data
|
|
301
|
+
keys = field_path.split('.')
|
|
302
|
+
value = data
|
|
303
|
+
for key in keys:
|
|
304
|
+
if isinstance(value, dict):
|
|
305
|
+
value = value.get(key)
|
|
306
|
+
else:
|
|
307
|
+
return None
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
if __name__ == '__main__':
|
|
311
|
+
class MockResponse:
|
|
312
|
+
def __init__(self, json_data):
|
|
313
|
+
self._json_data = json_data
|
|
314
|
+
|
|
315
|
+
def json(self):
|
|
316
|
+
return self._json_data
|
|
317
|
+
|
|
318
|
+
assert_var = [
|
|
319
|
+
{
|
|
320
|
+
"type": "equal",
|
|
321
|
+
"field": "code",
|
|
322
|
+
"expected": 0
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"type": "is not None",
|
|
326
|
+
"field": "data.id"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
"type": "equal",
|
|
330
|
+
"field": "message",
|
|
331
|
+
"expected": "success"
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
response_var = {'code': 0, 'message': 'success', 'data': {}}
|
|
335
|
+
|
|
336
|
+
AssertHandler().handle_assertion(asserts=assert_var, response=response_var)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @time: 2024-08-01
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import yaml
|
|
6
|
+
from atf.core.globals import Globals
|
|
7
|
+
from atf.core.log_manager import log
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigManager:
|
|
11
|
+
"""
|
|
12
|
+
用于处理 config.yaml 的配置管理类
|
|
13
|
+
"""
|
|
14
|
+
_config = None # 类变量,用于存储加载的配置
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def load_config(cls):
|
|
18
|
+
if cls._config is not None:
|
|
19
|
+
return cls._config # 如果已加载,则直接返回
|
|
20
|
+
|
|
21
|
+
# config.yaml 位于项目根目录,core/ 在 atf/ 之下,需要返回两级
|
|
22
|
+
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../config.yaml")
|
|
23
|
+
try:
|
|
24
|
+
with open(config_path, 'r') as file:
|
|
25
|
+
cls._config = yaml.safe_load(file)
|
|
26
|
+
if cls._config is None:
|
|
27
|
+
log.error(f"{config_path} 未找到配置信息")
|
|
28
|
+
raise ValueError(f"{config_path} 未找到配置信息")
|
|
29
|
+
log.info("读取工程配置信息成功")
|
|
30
|
+
|
|
31
|
+
# 将DingTalk配置信息存入Globals
|
|
32
|
+
dingtalk_config = cls._config.get('notifications').get("dingtalk")
|
|
33
|
+
Globals.set("dingtalk", dingtalk_config)
|
|
34
|
+
|
|
35
|
+
return cls._config
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
log.error(f"未找到配置文件: {config_path}")
|
|
38
|
+
raise FileNotFoundError(f"未找到配置文件: {config_path}")
|
|
39
|
+
except yaml.YAMLError as e:
|
|
40
|
+
log.error(f"YAML 配置文件解析错误: {e}")
|
|
41
|
+
raise ValueError(f"YAML 配置文件解析失败: {e}")
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def get_project_env_config(cls, project_name, env):
|
|
45
|
+
"""
|
|
46
|
+
获取指定项目和环境的配置信息
|
|
47
|
+
:param project_name: 项目名称
|
|
48
|
+
:param env: 环境名称 (如 test, pre, online)
|
|
49
|
+
:return: 项目的环境配置字典或 None
|
|
50
|
+
"""
|
|
51
|
+
config = cls.load_config()
|
|
52
|
+
project_config = config["projects"].get(project_name, None)
|
|
53
|
+
|
|
54
|
+
if project_config is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
final_config = {}
|
|
58
|
+
|
|
59
|
+
if "is_scene" in project_config and project_config["is_scene"]:
|
|
60
|
+
for sub_project in project_config.get("sub_projects", []):
|
|
61
|
+
sub_project_config = config["projects"].get(sub_project, None)
|
|
62
|
+
if sub_project_config is None:
|
|
63
|
+
log.warning(f"子项目{sub_project}在配置文件中不存在")
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
sub_project_env_config = sub_project_config.get(env, None)
|
|
67
|
+
if sub_project_env_config:
|
|
68
|
+
cls.validate_config(sub_project_env_config)
|
|
69
|
+
final_config[sub_project] = sub_project_env_config
|
|
70
|
+
else:
|
|
71
|
+
log.warning(f"子项目 {sub_project} 中未找到环境 {env} 的配置信息,返回 None")
|
|
72
|
+
final_config[sub_project] = None
|
|
73
|
+
# 存储子项目配置信息到Globals中
|
|
74
|
+
Globals.set(sub_project, sub_project_env_config)
|
|
75
|
+
else:
|
|
76
|
+
project_env_config = project_config.get(env, None)
|
|
77
|
+
if project_env_config:
|
|
78
|
+
cls.validate_config(project_env_config)
|
|
79
|
+
final_config[project_name] = project_env_config
|
|
80
|
+
else:
|
|
81
|
+
log.warning(f"项目 {project_name} 中未找到环境 {env} 的配置信息,返回 None")
|
|
82
|
+
final_config[project_name] = None
|
|
83
|
+
# 存储项目配置信息到Globals中
|
|
84
|
+
Globals.set(project_name, project_env_config)
|
|
85
|
+
|
|
86
|
+
return final_config
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def validate_config(project_env_config):
|
|
90
|
+
"""
|
|
91
|
+
验证配置文件的项目环境配置中是否有必需字段
|
|
92
|
+
:param project_env_config: 项目的环境配置字典
|
|
93
|
+
"""
|
|
94
|
+
required_keys = ['host', 'is_need_login'] # 需要验证的关键字段
|
|
95
|
+
for key in required_keys:
|
|
96
|
+
if key not in project_env_config:
|
|
97
|
+
log.error(f"{project_env_config} 中缺少必需配置项 {key}")
|
|
98
|
+
raise ValueError(f"{project_env_config} 中缺少必需配置项 {key}")
|
|
99
|
+
|
|
100
|
+
if project_env_config['is_need_login']:
|
|
101
|
+
login_config = project_env_config.get('login', {})
|
|
102
|
+
required_login_keys = ['url', 'method', 'data']
|
|
103
|
+
for key in required_login_keys:
|
|
104
|
+
if key not in login_config:
|
|
105
|
+
log.error(f"{project_env_config} 中登录配置缺失或不完整")
|
|
106
|
+
raise ValueError(f"{project_env_config} 中登录配置缺失或不完整")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == '__main__':
|
|
110
|
+
conf = ConfigManager.get_project_env_config(project_name="merchant", env="pre")
|
|
111
|
+
print(conf)
|
atf/core/globals.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @time: 2024-08-16
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
class Globals:
|
|
7
|
+
"""
|
|
8
|
+
全局变量管理,线程安全
|
|
9
|
+
"""
|
|
10
|
+
_instance = None
|
|
11
|
+
_lock = threading.Lock()
|
|
12
|
+
_data = {}
|
|
13
|
+
|
|
14
|
+
def __new__(cls):
|
|
15
|
+
if cls._instance is None:
|
|
16
|
+
with cls._lock:
|
|
17
|
+
if cls._instance is None:
|
|
18
|
+
cls._instance = super(Globals, cls).__new__(cls)
|
|
19
|
+
return cls._instance
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_data(cls):
|
|
23
|
+
with cls._lock:
|
|
24
|
+
return cls._data.copy()
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def set(cls, key, value):
|
|
28
|
+
with cls._lock:
|
|
29
|
+
# 仅在数据发生变化时才进行设置
|
|
30
|
+
if cls._data.get(key) != value:
|
|
31
|
+
cls._data[key] = value
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def get(cls, key):
|
|
35
|
+
with cls._lock:
|
|
36
|
+
return cls._data.get(key)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def update(cls, key, sub_key, sub_value):
|
|
40
|
+
with cls._lock:
|
|
41
|
+
if key in cls._data:
|
|
42
|
+
if isinstance(cls._data[key], dict):
|
|
43
|
+
cls._data[key][sub_key] = sub_value
|
|
44
|
+
else:
|
|
45
|
+
raise ValueError(f"Cannot update non-dict item in Globals: {key}")
|
|
46
|
+
else:
|
|
47
|
+
raise KeyError(f"Key {key} not found in Globals")
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def clear(cls):
|
|
51
|
+
with cls._lock:
|
|
52
|
+
cls._data.clear()
|
atf/core/log_manager.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @time: 2024-08-01
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
class LogManager:
|
|
9
|
+
'''
|
|
10
|
+
日志记录器封装
|
|
11
|
+
'''
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def setup_logging():
|
|
15
|
+
root_path = os.path.dirname(os.path.abspath(__file__)) # 根目录
|
|
16
|
+
log_dir = os.path.join(root_path, "../logs")
|
|
17
|
+
if not os.path.exists(log_dir):
|
|
18
|
+
os.makedirs(log_dir)
|
|
19
|
+
|
|
20
|
+
log_name = datetime.now().strftime("%Y-%m-%d") # 日志文件名命名格式为“年-月-日”
|
|
21
|
+
sink = os.path.join(log_dir, "{}.log".format(log_name)) # 日志文件路径
|
|
22
|
+
level = "DEBUG" # 记录的最低日志级别为DEBUG
|
|
23
|
+
encoding = "utf-8" # 写入日志文件时编码格式为utf-8
|
|
24
|
+
enqueue = True # 多线程多进程时保证线程安全
|
|
25
|
+
rotation = "500MB" # 日志文件最大为500MB,超过则新建文件记录日志
|
|
26
|
+
retention = "1 week" # 日志保留时长为1星期,超时则清除
|
|
27
|
+
|
|
28
|
+
logger.add(
|
|
29
|
+
sink=sink,
|
|
30
|
+
level=level,
|
|
31
|
+
encoding=encoding,
|
|
32
|
+
enqueue=enqueue,
|
|
33
|
+
rotation=rotation,
|
|
34
|
+
retention=retention
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def get_logger():
|
|
39
|
+
return logger
|
|
40
|
+
|
|
41
|
+
# 在工程开始时初始化设置日志记录器
|
|
42
|
+
LogManager.setup_logging()
|
|
43
|
+
|
|
44
|
+
# 获取日志记录器实例,用于其他模块调用
|
|
45
|
+
log = LogManager.get_logger()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == '__main__':
|
|
49
|
+
log.debug("debug消息")
|
|
50
|
+
log.info("info消息")
|
|
51
|
+
log.warning("warning消息")
|
|
52
|
+
log.error("error消息")
|