api-engine-xin 0.0.26__tar.gz → 0.0.29__tar.gz
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.
- api_engine_xin-0.0.29/ApiEngine/core.py +195 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/base_case.py +38 -7
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/precondition.py +30 -7
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/__init__.py +0 -1
- api_engine_xin-0.0.29/ApiEngine/infra/suite_store.py +98 -0
- api_engine_xin-0.0.29/PKG-INFO +539 -0
- api_engine_xin-0.0.29/README.md +512 -0
- api_engine_xin-0.0.29/api_engine_xin.egg-info/PKG-INFO +539 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/SOURCES.txt +2 -1
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/setup.py +1 -1
- api_engine_xin-0.0.26/ApiEngine/core.py +0 -422
- api_engine_xin-0.0.26/PKG-INFO +0 -672
- api_engine_xin-0.0.26/README.md +0 -645
- api_engine_xin-0.0.26/api_engine_xin.egg-info/PKG-INFO +0 -672
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/__init__.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/__init__.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/assertion.py +0 -0
- {api_engine_xin-0.0.26/ApiEngine/infra → api_engine_xin-0.0.29/ApiEngine/engine}/exceptions.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/extractor.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/replacer.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/engine/script_runner.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/__init__.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/client.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/http/files.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/case_log.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/db_client.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/global_func.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/ApiEngine/infra/test_result.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/LICENSE +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/dependency_links.txt +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/requires.txt +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/api_engine_xin.egg-info/top_level.txt +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/setup.cfg +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/Tools.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/__init__.py +0 -0
- {api_engine_xin-0.0.26 → api_engine_xin-0.0.29}/tests/runTest.py +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ApiEngine import log
|
|
5
|
+
from ApiEngine.infra import global_func
|
|
6
|
+
from ApiEngine.engine.exceptions import PreconditionChainError
|
|
7
|
+
from ApiEngine.infra.test_result import TestResult
|
|
8
|
+
from ApiEngine.infra.db_client import DBClient
|
|
9
|
+
from ApiEngine.infra.suite_store import RunRegistry, RunStore
|
|
10
|
+
from ApiEngine.engine.base_case import BaseCase
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestRunner:
|
|
14
|
+
def __init__(self, env_data, run_id=None):
|
|
15
|
+
"""
|
|
16
|
+
:param env_data: 执行测试时的环境数据
|
|
17
|
+
:param run_id: 套件运行ID(传入时启用套件级变量共享)
|
|
18
|
+
"""
|
|
19
|
+
# 深拷贝环境数据,避免修改调用方原始数据
|
|
20
|
+
self.env_data = copy.deepcopy(env_data)
|
|
21
|
+
# debug_updates 保持原引用,变更对上层可见(仅无 run_id 的退化路径使用)
|
|
22
|
+
if "debug_updates" in env_data:
|
|
23
|
+
self.env_data["debug_updates"] = env_data["debug_updates"]
|
|
24
|
+
self.result = []
|
|
25
|
+
self._db = DBClient()
|
|
26
|
+
self._shared_env = {} # 实例级共享环境
|
|
27
|
+
self._run_id = run_id
|
|
28
|
+
self._suite_store: Optional[RunStore] = RunRegistry.get(run_id) if run_id else None
|
|
29
|
+
|
|
30
|
+
def execute_cases(self, testcases):
|
|
31
|
+
"""执行测试用例的方法"""
|
|
32
|
+
# 根据数据库的配置初始化数据库的连接
|
|
33
|
+
db_config = self.env_data.pop("db", None)
|
|
34
|
+
if db_config:
|
|
35
|
+
self._db.init_connect(db_config)
|
|
36
|
+
|
|
37
|
+
# 统一初始化共享环境
|
|
38
|
+
self._shared_env.clear()
|
|
39
|
+
self._shared_env.update(self.env_data)
|
|
40
|
+
|
|
41
|
+
# 如果有 run_id,将 _shared_env["envs"] 和 debug_updates 指向套件级存储
|
|
42
|
+
# 这样所有变量读写(包括 Replacer、用户脚本、前置用例提取)自动走 _suite_stores
|
|
43
|
+
if self._suite_store:
|
|
44
|
+
self._shared_env["envs"] = self._suite_store.envs
|
|
45
|
+
self._shared_env["debug_updates"] = self._suite_store.debug_updates
|
|
46
|
+
self._shared_env["_run_store"] = self._suite_store # 供 BaseCase 心跳 touch
|
|
47
|
+
|
|
48
|
+
self._load_global_func()
|
|
49
|
+
|
|
50
|
+
# 判断测试数据参数的类型
|
|
51
|
+
if isinstance(testcases, dict):
|
|
52
|
+
cases = testcases.get("cases")
|
|
53
|
+
if cases:
|
|
54
|
+
log.info_log("执行测试套件:", testcases["name"])
|
|
55
|
+
test_result = TestResult(all=len(testcases["cases"]), name=testcases["name"])
|
|
56
|
+
for case in testcases["cases"]:
|
|
57
|
+
log.info_log(case)
|
|
58
|
+
self.perform_case(case, test_result)
|
|
59
|
+
res = test_result.get_result_info()
|
|
60
|
+
self.result.append(res)
|
|
61
|
+
else:
|
|
62
|
+
log.info_log("调试单条接口用例:", testcases["title"])
|
|
63
|
+
test_result = TestResult(all=1)
|
|
64
|
+
self.perform_case(testcases, test_result)
|
|
65
|
+
res = test_result.get_result_info()
|
|
66
|
+
self.result = res["cases"][0]
|
|
67
|
+
elif isinstance(testcases, list):
|
|
68
|
+
results = []
|
|
69
|
+
for items in testcases:
|
|
70
|
+
log.info_log("执行测试套件:", items["name"])
|
|
71
|
+
test_result = TestResult(all=len(items["cases"]), name=items["name"])
|
|
72
|
+
for case in items["cases"]:
|
|
73
|
+
self.perform_case(case, test_result)
|
|
74
|
+
res = test_result.get_result_info()
|
|
75
|
+
results.append(res)
|
|
76
|
+
total_all = 0
|
|
77
|
+
total_success = 0
|
|
78
|
+
total_fail = 0
|
|
79
|
+
total_error = 0
|
|
80
|
+
for scence_result in results:
|
|
81
|
+
total_all += scence_result["all"]
|
|
82
|
+
total_success += scence_result["success"]
|
|
83
|
+
total_fail += scence_result["fail"]
|
|
84
|
+
total_error += scence_result["error"]
|
|
85
|
+
self.result = {
|
|
86
|
+
"results": results,
|
|
87
|
+
"all": total_all,
|
|
88
|
+
"success": total_success,
|
|
89
|
+
"fail": total_fail,
|
|
90
|
+
"error": total_error
|
|
91
|
+
}
|
|
92
|
+
else:
|
|
93
|
+
log.error_log("测试数据格式错误")
|
|
94
|
+
|
|
95
|
+
# 断开数据库连接
|
|
96
|
+
if db_config:
|
|
97
|
+
self._db.close_db_connect()
|
|
98
|
+
return self.result
|
|
99
|
+
|
|
100
|
+
def perform_case(self, case, test_result):
|
|
101
|
+
"""执行单条用例"""
|
|
102
|
+
# 心跳:每开始一条用例就刷新活跃时间,防止长套件被 cleanup 误清
|
|
103
|
+
if self._run_id:
|
|
104
|
+
RunRegistry.touch(self._run_id)
|
|
105
|
+
# 每次创建新的 BaseCase 实例,传入共享环境引用
|
|
106
|
+
c = BaseCase(shared_env=self._shared_env)
|
|
107
|
+
c._db = self._db
|
|
108
|
+
try:
|
|
109
|
+
c.perform(case)
|
|
110
|
+
except PreconditionChainError:
|
|
111
|
+
test_result.add_fail(c)
|
|
112
|
+
except AssertionError:
|
|
113
|
+
test_result.add_fail(c)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
test_result.add_error(c, e)
|
|
116
|
+
else:
|
|
117
|
+
test_result.add_success(c)
|
|
118
|
+
|
|
119
|
+
def _load_global_func(self):
|
|
120
|
+
"""安全加载用户自定义函数,避免跨执行污染"""
|
|
121
|
+
_gf = self.env_data.get("global_func")
|
|
122
|
+
if not (_gf and isinstance(_gf, str)):
|
|
123
|
+
return
|
|
124
|
+
# 只保留 __name__ 等双下划线内置属性
|
|
125
|
+
builtins = {k: v for k, v in global_func.__dict__.items() if k.startswith('__')}
|
|
126
|
+
global_func.__dict__.clear()
|
|
127
|
+
global_func.__dict__.update(builtins)
|
|
128
|
+
exec(_gf, global_func.__dict__)
|
|
129
|
+
|
|
130
|
+
def get_env_snapshot(self) -> dict:
|
|
131
|
+
"""获取执行后的环境变量快照(供上层平台读取)
|
|
132
|
+
|
|
133
|
+
返回包含 envs、debug_updates 的字典,
|
|
134
|
+
用于上层平台同步临时变量和持久化全局变量变更。
|
|
135
|
+
|
|
136
|
+
debug_updates 中的约定:
|
|
137
|
+
- value 非 None → 新增/更新全局变量
|
|
138
|
+
- value 为 None → 删除全局变量
|
|
139
|
+
"""
|
|
140
|
+
return {
|
|
141
|
+
"envs": copy.deepcopy(dict(self._shared_env.get("envs") or {})),
|
|
142
|
+
"debug_updates": copy.deepcopy(self._shared_env.get("debug_updates") or {}),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# ==================== 套件级共享变量:生命周期管理 ====================
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def register_run(cls, run_id: str, initial_envs: dict) -> None:
|
|
149
|
+
"""套件开始前注册共享存储。
|
|
150
|
+
|
|
151
|
+
:param run_id: 唯一运行标识(建议格式: suite-{id}-{run_id}-{timestamp})
|
|
152
|
+
:param initial_envs: 初始环境变量(用户配置的基线值,含 DB 全局变量)
|
|
153
|
+
"""
|
|
154
|
+
RunRegistry.register(run_id, initial_envs)
|
|
155
|
+
|
|
156
|
+
@classmethod
|
|
157
|
+
def unregister_run(cls, run_id: str) -> None:
|
|
158
|
+
"""套件结束后清理共享存储。务必在 finally 中调用。"""
|
|
159
|
+
RunRegistry.unregister(run_id)
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def get_debug_updates(cls, run_id: str) -> dict:
|
|
163
|
+
"""获取需要持久化到 DB 的全局变量变更队列。
|
|
164
|
+
|
|
165
|
+
返回 dict 副本:
|
|
166
|
+
- value 非 None → upsert
|
|
167
|
+
- value 为 None → delete
|
|
168
|
+
"""
|
|
169
|
+
store = RunRegistry.get(run_id)
|
|
170
|
+
if not store:
|
|
171
|
+
return {}
|
|
172
|
+
with store.lock:
|
|
173
|
+
return dict(store.debug_updates)
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def clear_debug_updates(cls, run_id: str) -> None:
|
|
177
|
+
"""重置变更队列(平台写 DB 后调用,避免重复写入)。"""
|
|
178
|
+
store = RunRegistry.get(run_id)
|
|
179
|
+
if store:
|
|
180
|
+
with store.lock:
|
|
181
|
+
store.debug_updates.clear()
|
|
182
|
+
|
|
183
|
+
@classmethod
|
|
184
|
+
def get_run_globals(cls, run_id: str) -> dict:
|
|
185
|
+
"""获取当前套件累积的全部环境变量(含初始基线 + 运行时设置)。"""
|
|
186
|
+
store = RunRegistry.get(run_id)
|
|
187
|
+
if not store:
|
|
188
|
+
return {}
|
|
189
|
+
with store.lock:
|
|
190
|
+
return dict(store.envs)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def cleanup_stale_runs(cls, max_age_seconds: float = 3600) -> None:
|
|
194
|
+
"""清理超时的残留 store(防止异常中断导致内存泄漏)。"""
|
|
195
|
+
RunRegistry.cleanup_stale(max_age_seconds)
|
|
@@ -41,9 +41,13 @@ class BaseCase(CaseLogHandler):
|
|
|
41
41
|
|
|
42
42
|
# ==================== HTTP 请求 ====================
|
|
43
43
|
|
|
44
|
-
def _send_request(self, data):
|
|
44
|
+
def _send_request(self, data, http_client=None):
|
|
45
45
|
"""构建并发送请求"""
|
|
46
|
-
http_client
|
|
46
|
+
if http_client is None:
|
|
47
|
+
http_client = HttpClient()
|
|
48
|
+
self._owns_http_client = True
|
|
49
|
+
else:
|
|
50
|
+
self._owns_http_client = False
|
|
47
51
|
self._http_client = http_client
|
|
48
52
|
replacer = Replacer(self._shared_env, self.env, self.info_log)
|
|
49
53
|
request_data = http_client.build_request(data, self._shared_env, replacer)
|
|
@@ -182,12 +186,18 @@ class BaseCase(CaseLogHandler):
|
|
|
182
186
|
self._assert_results = []
|
|
183
187
|
case_name = data.get('title')
|
|
184
188
|
has_failure = False
|
|
189
|
+
shared_http_client = None
|
|
185
190
|
|
|
186
191
|
try:
|
|
192
|
+
# 创建共享 HTTP session(前置步骤 + 主用例共用 Cookie)
|
|
193
|
+
shared_http_client = HttpClient()
|
|
194
|
+
|
|
187
195
|
# 1、前置条件链
|
|
188
196
|
if data.get("preconditions"):
|
|
189
197
|
self.info_log("========== 开始执行前置步骤链 ==========")
|
|
190
|
-
precond_executor = PreconditionExecutor(
|
|
198
|
+
precond_executor = PreconditionExecutor(
|
|
199
|
+
self._shared_env, self._db, http_client=shared_http_client
|
|
200
|
+
)
|
|
191
201
|
self._precondition_errors, self._precondition_results = precond_executor.execute(
|
|
192
202
|
data.get("preconditions"), depth=1, log_handler=self
|
|
193
203
|
)
|
|
@@ -203,7 +213,7 @@ class BaseCase(CaseLogHandler):
|
|
|
203
213
|
# 2、前置脚本 + 发送请求
|
|
204
214
|
self.info_log(f"开始执行用例步骤:{case_name}")
|
|
205
215
|
self._setup_script(data)
|
|
206
|
-
response = self._send_request(data)
|
|
216
|
+
response = self._send_request(data, http_client=shared_http_client)
|
|
207
217
|
|
|
208
218
|
# 3、数据提取
|
|
209
219
|
if data.get("extract"):
|
|
@@ -232,9 +242,10 @@ class BaseCase(CaseLogHandler):
|
|
|
232
242
|
raise
|
|
233
243
|
|
|
234
244
|
finally:
|
|
235
|
-
#
|
|
245
|
+
# 关闭共享 http client
|
|
246
|
+
if shared_http_client:
|
|
247
|
+
shared_http_client.close()
|
|
236
248
|
if hasattr(self, '_http_client'):
|
|
237
|
-
self._http_client.close()
|
|
238
249
|
delattr(self, '_http_client')
|
|
239
250
|
|
|
240
251
|
end_time = time.time()
|
|
@@ -244,8 +255,18 @@ class BaseCase(CaseLogHandler):
|
|
|
244
255
|
|
|
245
256
|
# ==================== 环境变量操作 ====================
|
|
246
257
|
|
|
258
|
+
def _touch_store(self):
|
|
259
|
+
"""心跳:刷新 RunStore 活跃时间,防止长运行被误清"""
|
|
260
|
+
try:
|
|
261
|
+
run_store = self._shared_env.get("_run_store")
|
|
262
|
+
if run_store:
|
|
263
|
+
run_store.touch()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
|
|
247
267
|
def _save_env_variable(self, key, value):
|
|
248
268
|
"""保存临时环境变量"""
|
|
269
|
+
self._touch_store()
|
|
249
270
|
self.info_log(f"保存(临时)环境变量:{key} = {value}")
|
|
250
271
|
self.env[key] = value
|
|
251
272
|
try:
|
|
@@ -261,11 +282,20 @@ class BaseCase(CaseLogHandler):
|
|
|
261
282
|
|
|
262
283
|
def del_evn_variable(self, key):
|
|
263
284
|
"""删除测试运行环境变量"""
|
|
285
|
+
self._touch_store()
|
|
264
286
|
self.info_log(f"删除(临时)环境变量:{key}")
|
|
265
|
-
|
|
287
|
+
self.env.pop(key, None)
|
|
288
|
+
# 同时从套件级共享存储中删除,防止后续用例读到已删除的变量
|
|
289
|
+
try:
|
|
290
|
+
envs = self._shared_env.get("envs")
|
|
291
|
+
if isinstance(envs, dict):
|
|
292
|
+
envs.pop(key, None)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
266
295
|
|
|
267
296
|
def save_global_variable(self, key, value):
|
|
268
297
|
"""保存测试运行环境的全局变量"""
|
|
298
|
+
self._touch_store()
|
|
269
299
|
self.info_log(f"保存全局变量:{key} = {value}")
|
|
270
300
|
envs = self._shared_env.get("envs")
|
|
271
301
|
if isinstance(envs, dict):
|
|
@@ -280,6 +310,7 @@ class BaseCase(CaseLogHandler):
|
|
|
280
310
|
|
|
281
311
|
def del_global_variable(self, key):
|
|
282
312
|
"""删除测试运行环境的全局变量"""
|
|
313
|
+
self._touch_store()
|
|
283
314
|
self.info_log(f"删除全局变量:{key}")
|
|
284
315
|
envs = self._shared_env.get("envs")
|
|
285
316
|
if isinstance(envs, dict) and key in envs:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from ApiEngine.
|
|
1
|
+
from ApiEngine.engine.exceptions import PreconditionChainError
|
|
2
2
|
from ApiEngine.http.client import HttpClient
|
|
3
3
|
from ApiEngine.engine.script_runner import ScriptRunner
|
|
4
4
|
from ApiEngine.engine.extractor import Extractor
|
|
@@ -8,11 +8,12 @@ from ApiEngine.engine.assertion import AssertionEngine
|
|
|
8
8
|
class PreconditionExecutor:
|
|
9
9
|
"""前置条件递归执行器(深度优先 DFS)"""
|
|
10
10
|
|
|
11
|
-
def __init__(self, shared_env, db):
|
|
11
|
+
def __init__(self, shared_env, db, http_client=None):
|
|
12
12
|
self._shared_env = shared_env
|
|
13
13
|
self._db = db
|
|
14
14
|
self._extractor = Extractor()
|
|
15
15
|
self._precondition_results = []
|
|
16
|
+
self._shared_http_client = http_client # 共享 HTTP session
|
|
16
17
|
|
|
17
18
|
def execute(self, steps, depth=1, failure_mode="continue", log_handler=None):
|
|
18
19
|
"""
|
|
@@ -47,6 +48,7 @@ class PreconditionExecutor:
|
|
|
47
48
|
|
|
48
49
|
# 单步执行
|
|
49
50
|
step_error = None
|
|
51
|
+
has_assertion_error = False
|
|
50
52
|
response = None
|
|
51
53
|
step_assert_results = []
|
|
52
54
|
step_extract_results = []
|
|
@@ -65,9 +67,12 @@ class PreconditionExecutor:
|
|
|
65
67
|
)
|
|
66
68
|
next(script_hook)
|
|
67
69
|
|
|
68
|
-
#
|
|
70
|
+
# 发送请求:使用共享 session,无共享则创建临时实例
|
|
69
71
|
from ApiEngine.engine.replacer import Replacer
|
|
70
|
-
|
|
72
|
+
if self._shared_http_client:
|
|
73
|
+
http_client = self._shared_http_client
|
|
74
|
+
else:
|
|
75
|
+
http_client = HttpClient()
|
|
71
76
|
temp_env = {}
|
|
72
77
|
replacer = Replacer(self._shared_env, temp_env, log_handler.info_log)
|
|
73
78
|
request_data = http_client.build_request(step, self._shared_env, replacer)
|
|
@@ -180,6 +185,13 @@ class PreconditionExecutor:
|
|
|
180
185
|
log_handler.info_log(f"{prefix} 断言汇总:{total} 个全部通过 ✅")
|
|
181
186
|
|
|
182
187
|
except AssertionError as e:
|
|
188
|
+
has_assertion_error = True
|
|
189
|
+
step_error = {
|
|
190
|
+
"level": depth,
|
|
191
|
+
"step_title": title,
|
|
192
|
+
"error_type": "ASSERTION_FAILED",
|
|
193
|
+
"message": str(e)
|
|
194
|
+
}
|
|
183
195
|
log_handler.warning_log(f"{prefix}❌ [L{depth}] 存在断言失败: {title} — {e}")
|
|
184
196
|
|
|
185
197
|
except Exception as e:
|
|
@@ -199,8 +211,8 @@ class PreconditionExecutor:
|
|
|
199
211
|
except StopIteration:
|
|
200
212
|
pass
|
|
201
213
|
|
|
202
|
-
#
|
|
203
|
-
if http_client:
|
|
214
|
+
# 仅关闭非共享的 http client
|
|
215
|
+
if http_client and not self._shared_http_client:
|
|
204
216
|
http_client.close()
|
|
205
217
|
|
|
206
218
|
# 构建步骤结果
|
|
@@ -213,8 +225,16 @@ class PreconditionExecutor:
|
|
|
213
225
|
_elapsed = "{} ms".format(int(response.elapsed.total_seconds() * 1000))
|
|
214
226
|
except Exception:
|
|
215
227
|
pass
|
|
228
|
+
# 推断步骤状态
|
|
229
|
+
if step_error:
|
|
230
|
+
step_status = "fail"
|
|
231
|
+
elif has_assertion_error:
|
|
232
|
+
step_status = "fail"
|
|
233
|
+
else:
|
|
234
|
+
step_status = "success"
|
|
216
235
|
step_result = {
|
|
217
236
|
"title": title,
|
|
237
|
+
"status": step_status,
|
|
218
238
|
"status_code": getattr(log_handler, 'status_code', ''),
|
|
219
239
|
"response_headers": dict(getattr(log_handler, 'response_headers', {}) or {}),
|
|
220
240
|
"response_body": getattr(log_handler, 'response_body', ''),
|
|
@@ -227,7 +247,10 @@ class PreconditionExecutor:
|
|
|
227
247
|
"extract_info": list(step_extract_results),
|
|
228
248
|
}
|
|
229
249
|
self._precondition_results.append(step_result)
|
|
230
|
-
|
|
250
|
+
if step_error or has_assertion_error:
|
|
251
|
+
log_handler.warning_log(f"{prefix}❌ [L{depth}] 前置失败: {title}")
|
|
252
|
+
else:
|
|
253
|
+
log_handler.info_log(f"{prefix}✅ [L{depth}] 前置完成: {title}")
|
|
231
254
|
|
|
232
255
|
# 根据 failure_mode 决定是否继续
|
|
233
256
|
if step_error:
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""套件级共享变量存储(跨 TestRunner 实例)。
|
|
2
|
+
|
|
3
|
+
设计目的:
|
|
4
|
+
同一个套件内的多个用例各自创建独立的 TestRunner 实例,
|
|
5
|
+
但需要共享变量(save_env_variable / save_global_variable 设置的值)。
|
|
6
|
+
本模块提供类级别的注册表,用 run_id 关联同一套件的所有实例。
|
|
7
|
+
|
|
8
|
+
线程安全:
|
|
9
|
+
所有 dict 写操作通过 threading.Lock 保护,支持并行用例场景。
|
|
10
|
+
|
|
11
|
+
生命周期:
|
|
12
|
+
平台层在套件开始前调用 register_run(),结束后调用 unregister_run()。
|
|
13
|
+
cleanup_stale() 兜底清理异常中断导致的残留。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Dict, Optional, Set
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RunStore:
|
|
24
|
+
"""单次套件运行的共享变量存储。
|
|
25
|
+
|
|
26
|
+
envs: 套件级累积环境变量(含初始基线 + 运行中写入)
|
|
27
|
+
debug_updates: 需要持久化到 DB 的全局变量变更队列
|
|
28
|
+
temp_keys: 标记哪些 key 是 save_env_variable 写入的(区分 temp 与 global)
|
|
29
|
+
lock: 保护并发写操作
|
|
30
|
+
created_at: 注册时间戳(仅用于诊断日志)
|
|
31
|
+
last_active_at: 最后一次变量读写的时间戳(cleanup 依据)
|
|
32
|
+
"""
|
|
33
|
+
envs: dict = field(default_factory=dict)
|
|
34
|
+
debug_updates: dict = field(default_factory=dict)
|
|
35
|
+
temp_keys: Set[str] = field(default_factory=set)
|
|
36
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
37
|
+
created_at: float = field(default_factory=time.time)
|
|
38
|
+
last_active_at: float = field(default_factory=time.time)
|
|
39
|
+
|
|
40
|
+
def touch(self) -> None:
|
|
41
|
+
"""刷新活跃时间,防止长运行套件被 cleanup_stale 误清。"""
|
|
42
|
+
self.last_active_at = time.time()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RunRegistry:
|
|
46
|
+
"""类级别注册表:run_id → RunStore。
|
|
47
|
+
|
|
48
|
+
串行场景:后续用例可读到前面用例设置的变量。
|
|
49
|
+
并行场景:lock 保护 dict 操作,last-write-wins。
|
|
50
|
+
"""
|
|
51
|
+
_stores: Dict[str, RunStore] = {}
|
|
52
|
+
_registry_lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def register(cls, run_id: str, initial_envs: dict) -> RunStore:
|
|
56
|
+
"""注册套件运行,创建共享存储。"""
|
|
57
|
+
with cls._registry_lock:
|
|
58
|
+
store = RunStore(envs=dict(initial_envs or {}))
|
|
59
|
+
cls._stores[run_id] = store
|
|
60
|
+
return store
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def get(cls, run_id: str) -> Optional[RunStore]:
|
|
64
|
+
"""获取指定运行的共享存储。"""
|
|
65
|
+
return cls._stores.get(run_id)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def unregister(cls, run_id: str) -> None:
|
|
69
|
+
"""清理指定运行的共享存储。务必在 finally 中调用。"""
|
|
70
|
+
with cls._registry_lock:
|
|
71
|
+
cls._stores.pop(run_id, None)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def touch(cls, run_id: str) -> None:
|
|
75
|
+
"""刷新指定运行的活跃时间。变量读写时调用。"""
|
|
76
|
+
store = cls._stores.get(run_id)
|
|
77
|
+
if store:
|
|
78
|
+
store.touch()
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def cleanup_stale(cls, max_age_seconds: float = 3600) -> int:
|
|
82
|
+
"""清理超时的残留 store(防止异常中断导致内存泄漏)。
|
|
83
|
+
|
|
84
|
+
依据 last_active_at(最后一次变量读写)而非 created_at,
|
|
85
|
+
因此长时间运行但仍在活跃的套件不会被误清。
|
|
86
|
+
|
|
87
|
+
:param max_age_seconds: 超时阈值(秒),默认1小时
|
|
88
|
+
:return: 清理数量
|
|
89
|
+
"""
|
|
90
|
+
now = time.time()
|
|
91
|
+
with cls._registry_lock:
|
|
92
|
+
stale = [
|
|
93
|
+
rid for rid, s in cls._stores.items()
|
|
94
|
+
if now - s.last_active_at > max_age_seconds
|
|
95
|
+
]
|
|
96
|
+
for rid in stale:
|
|
97
|
+
cls._stores.pop(rid, None)
|
|
98
|
+
return len(stale)
|