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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unittest Tools
|
|
3
|
+
单元测试工具
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from atf.core.log_manager import log
|
|
12
|
+
from atf.mcp.models import GenerateResponse, UnitTestModel
|
|
13
|
+
from atf.mcp.tools.testcase_tools import format_validation_error
|
|
14
|
+
from atf.unit_case_generator import UnitCaseGenerator
|
|
15
|
+
from atf.mcp.utils import (
|
|
16
|
+
build_error_payload,
|
|
17
|
+
build_unittest_yaml,
|
|
18
|
+
contains_chinese,
|
|
19
|
+
expected_py_path,
|
|
20
|
+
log_tool_call,
|
|
21
|
+
new_request_id,
|
|
22
|
+
parse_unittest_input,
|
|
23
|
+
resolve_yaml_path,
|
|
24
|
+
yaml,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def register_unittest_tools(mcp: FastMCP) -> None:
|
|
29
|
+
"""注册单元测试工具"""
|
|
30
|
+
|
|
31
|
+
@mcp.tool(
|
|
32
|
+
name="write_unittest",
|
|
33
|
+
title="写入单元测试用例并生成 pytest 脚本",
|
|
34
|
+
description="根据输入的单元测试结构写入 YAML 文件,并生成对应的 pytest 单元测试脚本。\n\n"
|
|
35
|
+
"**命名规范**:\n"
|
|
36
|
+
"- `name` 字段**不能使用中文**,必须使用英文命名\n"
|
|
37
|
+
"- `description` 字段可以使用中文描述\n\n"
|
|
38
|
+
"**重要**: 必须传递 `workspace` 参数指定项目根目录,否则默认使用 api-auto-test 仓库。\n\n"
|
|
39
|
+
"**unittest 格式说明**:\n"
|
|
40
|
+
"```json\n"
|
|
41
|
+
"{\n"
|
|
42
|
+
" \"name\": \"user_service_test\", // 必须使用英文,不能包含中文\n"
|
|
43
|
+
" \"description\": \"用户服务单元测试\", // 描述可以使用中文\n"
|
|
44
|
+
" \"target\": {\n"
|
|
45
|
+
" \"module\": \"app.services.user_service\",\n"
|
|
46
|
+
" \"class\": \"UserService\", // 可选,测试类\n"
|
|
47
|
+
" \"function\": \"get_user\" // 可选,测试函数\n"
|
|
48
|
+
" },\n"
|
|
49
|
+
" \"fixtures\": {\n"
|
|
50
|
+
" \"setup\": [{\"type\": \"patch\", \"target\": \"app.services.user_service.UserRepository\", \"return_value\": {\"id\": 1, \"name\": \"test\"}}],\n"
|
|
51
|
+
" \"teardown\": []\n"
|
|
52
|
+
" },\n"
|
|
53
|
+
" \"cases\": [\n"
|
|
54
|
+
" {\n"
|
|
55
|
+
" \"id\": \"test_get_user_success\", // 必须使用英文,不能包含中文\n"
|
|
56
|
+
" \"description\": \"测试获取用户成功\", // 描述可以使用中文\n"
|
|
57
|
+
" \"inputs\": {\"args\": [1], \"kwargs\": {}},\n"
|
|
58
|
+
" \"assert\": [\n"
|
|
59
|
+
" {\"type\": \"equals\", \"field\": \"result.id\", \"expected\": 1},\n"
|
|
60
|
+
" {\"type\": \"equals\", \"field\": \"result.name\", \"expected\": \"test\"}\n"
|
|
61
|
+
" ]\n"
|
|
62
|
+
" }\n"
|
|
63
|
+
" ]\n"
|
|
64
|
+
"}\n"
|
|
65
|
+
"```\n\n"
|
|
66
|
+
"**assert.type 支持的断言类型**:\n"
|
|
67
|
+
"- `equals`: 精确匹配\n"
|
|
68
|
+
"- `not_equals`: 不匹配\n"
|
|
69
|
+
"- `contains`: 包含\n"
|
|
70
|
+
"- `raises`: 期望抛出异常,exception 字段指定异常类型\n"
|
|
71
|
+
"- `is_none`: 结果为 None\n"
|
|
72
|
+
"- `is_not_none`: 结果不为 None\n"
|
|
73
|
+
"- `called_once`: mock 被调用一次\n"
|
|
74
|
+
"- `called_with`: mock 被特定参数调用",
|
|
75
|
+
)
|
|
76
|
+
def write_unittest(
|
|
77
|
+
yaml_path: str,
|
|
78
|
+
unittest: UnitTestModel | dict | str,
|
|
79
|
+
overwrite: bool = False,
|
|
80
|
+
workspace: str | None = None,
|
|
81
|
+
) -> GenerateResponse:
|
|
82
|
+
request_id = new_request_id()
|
|
83
|
+
start_time = time.perf_counter()
|
|
84
|
+
try:
|
|
85
|
+
unittest_model = parse_unittest_input(unittest)
|
|
86
|
+
|
|
87
|
+
# 验证 name 字段不能包含中文
|
|
88
|
+
if contains_chinese(unittest_model.name):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"单元测试 name 字段不能包含中文字符: '{unittest_model.name}'\n"
|
|
91
|
+
"请使用英文命名,例如: user_service_test, calculate_total_test"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
yaml_full_path, yaml_relative_path, repo_root = resolve_yaml_path(yaml_path, workspace)
|
|
95
|
+
|
|
96
|
+
# 检查路径是否存在
|
|
97
|
+
if not repo_root.exists() or not repo_root.is_dir():
|
|
98
|
+
raise ValueError(f"工作目录不存在: {repo_root}")
|
|
99
|
+
|
|
100
|
+
py_full_path, py_relative_path = expected_py_path(
|
|
101
|
+
yaml_full_path=yaml_full_path,
|
|
102
|
+
testcase_name=unittest_model.name,
|
|
103
|
+
workspace=workspace,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if yaml_full_path.exists() and not overwrite:
|
|
107
|
+
raise ValueError("YAML 文件已存在,未开启覆盖写入")
|
|
108
|
+
if py_full_path.exists() and not overwrite:
|
|
109
|
+
raise ValueError("pytest 文件已存在,未开启覆盖写入")
|
|
110
|
+
|
|
111
|
+
yaml_full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
test_data = build_unittest_yaml(unittest_model)
|
|
113
|
+
with yaml_full_path.open("w", encoding="utf-8") as file:
|
|
114
|
+
yaml.safe_dump(test_data, file, allow_unicode=True, sort_keys=False)
|
|
115
|
+
|
|
116
|
+
if overwrite and py_full_path.exists():
|
|
117
|
+
py_full_path.unlink()
|
|
118
|
+
|
|
119
|
+
result = UnitCaseGenerator().generate_unit_tests(yaml_relative_path)
|
|
120
|
+
if not result:
|
|
121
|
+
raise ValueError("pytest 文件未生成,请检查单元测试数据格式")
|
|
122
|
+
|
|
123
|
+
response = GenerateResponse(
|
|
124
|
+
status="ok",
|
|
125
|
+
request_id=request_id,
|
|
126
|
+
written_files=[yaml_relative_path, py_relative_path],
|
|
127
|
+
)
|
|
128
|
+
except ValidationError as exc:
|
|
129
|
+
log.error(f"MCP 写入单元测试参数验证失败: {exc}")
|
|
130
|
+
error_details = {
|
|
131
|
+
"error_type": "validation_error",
|
|
132
|
+
"message": "参数格式错误,请检查以下字段:",
|
|
133
|
+
"details": format_validation_error(exc),
|
|
134
|
+
"hints": [
|
|
135
|
+
"assert.type 应该是: equals, not_equals, contains, raises, called_once, called_with, not_called, is_none, is_not_none",
|
|
136
|
+
"assert.field 应该为 result 或者不传",
|
|
137
|
+
"fixtures 格式暂不支持 function 类型,当前仅支持 patch 类型"
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
payload = build_error_payload(
|
|
141
|
+
code="MCP_VALIDATION_ERROR",
|
|
142
|
+
message=f"参数验证失败: {exc}",
|
|
143
|
+
retryable=False,
|
|
144
|
+
details=error_details,
|
|
145
|
+
)
|
|
146
|
+
response = GenerateResponse(
|
|
147
|
+
status="error",
|
|
148
|
+
request_id=request_id,
|
|
149
|
+
written_files=[],
|
|
150
|
+
**payload,
|
|
151
|
+
)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
log.error(f"MCP 写入单元测试业务验证失败: {exc}")
|
|
154
|
+
payload = build_error_payload(
|
|
155
|
+
code="MCP_VALUE_ERROR",
|
|
156
|
+
message=str(exc),
|
|
157
|
+
retryable=False,
|
|
158
|
+
details={"error_type": "value_error", "message": str(exc)},
|
|
159
|
+
)
|
|
160
|
+
response = GenerateResponse(
|
|
161
|
+
status="error",
|
|
162
|
+
request_id=request_id,
|
|
163
|
+
written_files=[],
|
|
164
|
+
**payload,
|
|
165
|
+
)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
log.error(f"MCP 写入单元测试失败: {exc}")
|
|
168
|
+
payload = build_error_payload(
|
|
169
|
+
code="MCP_WRITE_UNITTEST_ERROR",
|
|
170
|
+
message=f"未知错误: {type(exc).__name__}: {str(exc)}",
|
|
171
|
+
retryable=False,
|
|
172
|
+
details={"error_type": "unknown_error", "exception_type": type(exc).__name__},
|
|
173
|
+
)
|
|
174
|
+
response = GenerateResponse(
|
|
175
|
+
status="error",
|
|
176
|
+
request_id=request_id,
|
|
177
|
+
written_files=[],
|
|
178
|
+
**payload,
|
|
179
|
+
)
|
|
180
|
+
latency_ms = int((time.perf_counter() - start_time) * 1000)
|
|
181
|
+
log_tool_call(
|
|
182
|
+
"write_unittest",
|
|
183
|
+
request_id,
|
|
184
|
+
response.status,
|
|
185
|
+
latency_ms,
|
|
186
|
+
response.error_code,
|
|
187
|
+
meta={"yaml_path": yaml_path, "overwrite": overwrite},
|
|
188
|
+
)
|
|
189
|
+
return response
|
atf/mcp/utils.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server Utilities
|
|
3
|
+
工具函数集合
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import yaml as _yaml
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from atf.core.log_manager import log
|
|
18
|
+
from atf.case_generator import sanitize_name
|
|
19
|
+
from atf.mcp.models import (
|
|
20
|
+
TestcaseModel,
|
|
21
|
+
UnitTestModel,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# 统一导出 yaml 模块,供其他模块使用
|
|
25
|
+
yaml = _yaml
|
|
26
|
+
|
|
27
|
+
# ==================== 常量定义 ====================
|
|
28
|
+
REPO_ROOT = Path(os.getenv("ATF_REPO_ROOT", Path(__file__).resolve().parent.parent.parent))
|
|
29
|
+
TESTS_ROOT = REPO_ROOT / "tests"
|
|
30
|
+
CASES_ROOT = TESTS_ROOT / "cases" # YAML 测试用例目录
|
|
31
|
+
SCRIPTS_ROOT = TESTS_ROOT / "scripts" # 生成的 py 脚本目录
|
|
32
|
+
# 兼容旧版本
|
|
33
|
+
TEST_CASES_ROOT = SCRIPTS_ROOT
|
|
34
|
+
|
|
35
|
+
# pytest 执行常量
|
|
36
|
+
PYTEST_TIMEOUT = int(os.getenv("PYTEST_TIMEOUT", "300")) # 5分钟超时
|
|
37
|
+
MAX_ERROR_LENGTH = int(os.getenv("MAX_ERROR_LENGTH", "500")) # 错误信息最大长度
|
|
38
|
+
MAX_HISTORY_SIZE = int(os.getenv("MAX_HISTORY_SIZE", "1000")) # 历史记录最大条数
|
|
39
|
+
MCP_LOGS_ROOT = Path(os.getenv(
|
|
40
|
+
"MCP_LOGS_ROOT",
|
|
41
|
+
str(Path(__file__).resolve().parent.parent / "logs"),
|
|
42
|
+
)).expanduser()
|
|
43
|
+
MCP_CALLS_LOG = Path(os.getenv("MCP_CALLS_LOG", str(MCP_LOGS_ROOT / "mcp_calls.jsonl"))).expanduser()
|
|
44
|
+
MCP_LOG_CALLS_ENABLED = os.getenv("MCP_LOG_CALLS_ENABLED", "1").lower() in {"1", "true", "yes"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def new_request_id() -> str:
|
|
48
|
+
"""生成请求标识,用于关联日志与响应"""
|
|
49
|
+
return uuid.uuid4().hex[:12]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_error_payload(
|
|
53
|
+
code: str,
|
|
54
|
+
message: str,
|
|
55
|
+
retryable: bool = False,
|
|
56
|
+
details: dict | None = None,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""构建统一错误结构"""
|
|
59
|
+
return {
|
|
60
|
+
"error_code": code,
|
|
61
|
+
"retryable": retryable,
|
|
62
|
+
"error_message": message,
|
|
63
|
+
"error_details": details or {},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def log_tool_call(
|
|
68
|
+
tool_name: str,
|
|
69
|
+
request_id: str,
|
|
70
|
+
status: str,
|
|
71
|
+
latency_ms: int,
|
|
72
|
+
error_code: str | None = None,
|
|
73
|
+
meta: dict[str, Any] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""记录工具调用日志(JSONL)"""
|
|
76
|
+
if not MCP_LOG_CALLS_ENABLED:
|
|
77
|
+
return
|
|
78
|
+
record = {
|
|
79
|
+
"timestamp": datetime.now().astimezone().isoformat(),
|
|
80
|
+
"tool": tool_name,
|
|
81
|
+
"request_id": request_id,
|
|
82
|
+
"status": status,
|
|
83
|
+
"latency_ms": latency_ms,
|
|
84
|
+
}
|
|
85
|
+
if error_code:
|
|
86
|
+
record["error_code"] = error_code
|
|
87
|
+
if meta:
|
|
88
|
+
record["meta"] = meta
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
MCP_LOGS_ROOT.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
with MCP_CALLS_LOG.open("a", encoding="utf-8") as file:
|
|
93
|
+
file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
log.warning(f"MCP 调用日志写入失败: {exc}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_roots(workspace: str | None = None) -> tuple[Path, Path, Path, Path]:
|
|
99
|
+
"""根据 workspace 参数返回 (repo_root, tests_root, cases_root, scripts_root)
|
|
100
|
+
|
|
101
|
+
目录结构:
|
|
102
|
+
- tests/cases/ - YAML 测试用例
|
|
103
|
+
- tests/scripts/ - 生成的 py 脚本
|
|
104
|
+
"""
|
|
105
|
+
if workspace:
|
|
106
|
+
repo = Path(workspace).resolve(strict=False)
|
|
107
|
+
else:
|
|
108
|
+
repo = REPO_ROOT
|
|
109
|
+
tests = repo / "tests"
|
|
110
|
+
return repo, tests, tests / "cases", tests / "scripts"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_yaml_path(
|
|
114
|
+
yaml_path: str, workspace: str | None = None
|
|
115
|
+
) -> tuple[Path, str, Path]:
|
|
116
|
+
"""返回 (yaml_full_path, yaml_relative_path, repo_root)
|
|
117
|
+
|
|
118
|
+
YAML 文件应放在 tests/cases/ 目录下
|
|
119
|
+
"""
|
|
120
|
+
repo_root, _, cases_root, _ = get_roots(workspace)
|
|
121
|
+
raw_path = Path(yaml_path)
|
|
122
|
+
if raw_path.is_absolute():
|
|
123
|
+
normalized = raw_path.resolve(strict=False)
|
|
124
|
+
else:
|
|
125
|
+
# 如果是相对路径,优先在 cases_root 下查找
|
|
126
|
+
if (cases_root / raw_path).exists():
|
|
127
|
+
normalized = (cases_root / raw_path).resolve(strict=False)
|
|
128
|
+
else:
|
|
129
|
+
normalized = (repo_root / raw_path).resolve(strict=False)
|
|
130
|
+
if not normalized.name.endswith(".yaml"):
|
|
131
|
+
raise ValueError("yaml_path 必须以 .yaml 结尾")
|
|
132
|
+
if not normalized.is_relative_to(repo_root):
|
|
133
|
+
raise ValueError(f"yaml_path 必须在项目目录 {repo_root} 下")
|
|
134
|
+
relative_path = normalized.relative_to(repo_root).as_posix()
|
|
135
|
+
return normalized, relative_path, repo_root
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_tests_root(
|
|
139
|
+
root_path: str | None = None, workspace: str | None = None
|
|
140
|
+
) -> tuple[Path, Path]:
|
|
141
|
+
"""返回 (resolved_cases_root, repo_root)
|
|
142
|
+
|
|
143
|
+
默认返回 tests/cases/ 目录,如果目录不存在会自动创建
|
|
144
|
+
"""
|
|
145
|
+
repo_root, _, cases_root, _ = get_roots(workspace)
|
|
146
|
+
cases_root_resolved = cases_root.resolve(strict=False)
|
|
147
|
+
if not root_path:
|
|
148
|
+
# 直接创建,默认目录不存在则自动创建
|
|
149
|
+
try:
|
|
150
|
+
cases_root_resolved.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
except OSError as exc:
|
|
152
|
+
raise ValueError(f"无法创建目录 {cases_root_resolved}: {exc}") from exc
|
|
153
|
+
return cases_root_resolved, repo_root
|
|
154
|
+
|
|
155
|
+
raw_path = Path(root_path)
|
|
156
|
+
if raw_path.is_absolute():
|
|
157
|
+
normalized = raw_path.resolve(strict=False)
|
|
158
|
+
else:
|
|
159
|
+
normalized = (repo_root / raw_path).resolve(strict=False)
|
|
160
|
+
|
|
161
|
+
if not normalized.is_relative_to(repo_root):
|
|
162
|
+
raise ValueError(f"root_path 必须在项目目录 {repo_root} 下")
|
|
163
|
+
|
|
164
|
+
# 直接创建,exist_ok=True 避免并发竞态
|
|
165
|
+
try:
|
|
166
|
+
normalized.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
raise ValueError(f"无法创建目录 {normalized}: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
if not normalized.is_dir():
|
|
171
|
+
raise ValueError("root_path 必须是目录")
|
|
172
|
+
return normalized, repo_root
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def expected_py_path(
|
|
176
|
+
yaml_full_path: Path, testcase_name: str, workspace: str | None = None
|
|
177
|
+
) -> tuple[Path, str]:
|
|
178
|
+
"""返回 (py_full_path, py_relative_path)
|
|
179
|
+
|
|
180
|
+
目录映射规则:
|
|
181
|
+
- tests/cases/xxx.yaml → tests/scripts/test_xxx.py
|
|
182
|
+
- tests/cases/subdir/xxx.yaml → tests/scripts/subdir/test_xxx.py
|
|
183
|
+
"""
|
|
184
|
+
repo_root, _, cases_root, scripts_root = get_roots(workspace)
|
|
185
|
+
|
|
186
|
+
# 计算 YAML 相对于 cases_root 的子目录结构
|
|
187
|
+
if yaml_full_path.is_relative_to(cases_root):
|
|
188
|
+
# YAML 在 tests/cases/ 下
|
|
189
|
+
relative_to_cases = yaml_full_path.relative_to(cases_root)
|
|
190
|
+
directory_path = relative_to_cases.parent
|
|
191
|
+
if str(directory_path) == '.':
|
|
192
|
+
directory_path = Path()
|
|
193
|
+
else:
|
|
194
|
+
# 兼容旧路径:YAML 不在 cases_root 下,尝试保持相对结构
|
|
195
|
+
try:
|
|
196
|
+
tests_root = cases_root.parent
|
|
197
|
+
if yaml_full_path.is_relative_to(tests_root):
|
|
198
|
+
relative_to_tests = yaml_full_path.relative_to(tests_root)
|
|
199
|
+
directory_path = relative_to_tests.parent
|
|
200
|
+
if str(directory_path) == '.':
|
|
201
|
+
directory_path = Path()
|
|
202
|
+
else:
|
|
203
|
+
directory_path = Path()
|
|
204
|
+
except ValueError:
|
|
205
|
+
directory_path = Path()
|
|
206
|
+
|
|
207
|
+
# 使用 sanitize_name 确保文件名与 case_generator 生成的一致
|
|
208
|
+
safe_name = sanitize_name(testcase_name)
|
|
209
|
+
py_filename = f"test_{safe_name}.py"
|
|
210
|
+
py_full_path = (scripts_root / directory_path / py_filename).resolve(strict=False)
|
|
211
|
+
py_relative_path = py_full_path.relative_to(repo_root).as_posix()
|
|
212
|
+
return py_full_path, py_relative_path
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_yaml_file(path: Path) -> dict[str, Any]:
|
|
216
|
+
"""加载 YAML 文件"""
|
|
217
|
+
if not path.exists():
|
|
218
|
+
raise ValueError("YAML 文件不存在")
|
|
219
|
+
with path.open("r", encoding="utf-8") as file:
|
|
220
|
+
data = yaml.safe_load(file)
|
|
221
|
+
if data is None:
|
|
222
|
+
raise ValueError("YAML 文件内容为空")
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
raise ValueError("YAML 顶层必须是对象")
|
|
225
|
+
return data
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_testcase_yaml(testcase: TestcaseModel) -> dict[str, Any]:
|
|
229
|
+
"""构建测试用例 YAML 数据"""
|
|
230
|
+
payload = testcase.model_dump(by_alias=True, exclude_none=True)
|
|
231
|
+
return {"testcase": payload}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def build_unittest_yaml(unittest: UnitTestModel) -> dict[str, Any]:
|
|
235
|
+
"""构建单元测试 YAML 数据"""
|
|
236
|
+
payload = unittest.model_dump(by_alias=True, exclude_none=True)
|
|
237
|
+
return {"unittest": payload}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_testcase_input(raw_testcase: Any) -> TestcaseModel:
|
|
241
|
+
"""解析测试用例输入"""
|
|
242
|
+
if isinstance(raw_testcase, TestcaseModel):
|
|
243
|
+
return raw_testcase
|
|
244
|
+
|
|
245
|
+
data = raw_testcase
|
|
246
|
+
if isinstance(raw_testcase, str):
|
|
247
|
+
stripped = raw_testcase.strip()
|
|
248
|
+
if not stripped:
|
|
249
|
+
raise ValueError("testcase 不能为空")
|
|
250
|
+
try:
|
|
251
|
+
data = json.loads(stripped)
|
|
252
|
+
except json.JSONDecodeError as json_err:
|
|
253
|
+
log.debug(f"JSON 解析失败: {json_err}, 原始字符串: {stripped[:200]}")
|
|
254
|
+
try:
|
|
255
|
+
data = yaml.safe_load(stripped)
|
|
256
|
+
except yaml.YAMLError as exc:
|
|
257
|
+
raise ValueError(f"testcase 字符串解析失败, 原始内容: {stripped[:200]}") from exc
|
|
258
|
+
|
|
259
|
+
if not isinstance(data, dict):
|
|
260
|
+
raise ValueError("testcase 必须是对象")
|
|
261
|
+
|
|
262
|
+
if "testcase" in data and isinstance(data["testcase"], dict):
|
|
263
|
+
data = data["testcase"]
|
|
264
|
+
|
|
265
|
+
return TestcaseModel.model_validate(data)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_unittest_input(raw_unittest: Any) -> UnitTestModel:
|
|
269
|
+
"""解析单元测试输入"""
|
|
270
|
+
if isinstance(raw_unittest, UnitTestModel):
|
|
271
|
+
return raw_unittest
|
|
272
|
+
|
|
273
|
+
data = raw_unittest
|
|
274
|
+
if isinstance(raw_unittest, str):
|
|
275
|
+
stripped = raw_unittest.strip()
|
|
276
|
+
if not stripped:
|
|
277
|
+
raise ValueError("unittest 不能为空")
|
|
278
|
+
try:
|
|
279
|
+
data = json.loads(stripped)
|
|
280
|
+
except json.JSONDecodeError:
|
|
281
|
+
try:
|
|
282
|
+
data = yaml.safe_load(stripped)
|
|
283
|
+
except yaml.YAMLError as exc:
|
|
284
|
+
raise ValueError(f"unittest 字符串解析失败") from exc
|
|
285
|
+
|
|
286
|
+
if not isinstance(data, dict):
|
|
287
|
+
raise ValueError("unittest 必须是对象")
|
|
288
|
+
|
|
289
|
+
if "unittest" in data and isinstance(data["unittest"], dict):
|
|
290
|
+
data = data["unittest"]
|
|
291
|
+
|
|
292
|
+
return UnitTestModel.model_validate(data)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_testcase_summary(testcase: TestcaseModel) -> dict[str, Any]:
|
|
296
|
+
"""构建测试用例摘要"""
|
|
297
|
+
summary: dict[str, Any] = {
|
|
298
|
+
"name": testcase.name,
|
|
299
|
+
"steps": [
|
|
300
|
+
{"id": step.id, "path": step.path, "method": step.method}
|
|
301
|
+
for step in testcase.steps
|
|
302
|
+
],
|
|
303
|
+
}
|
|
304
|
+
if testcase.description:
|
|
305
|
+
summary["description"] = testcase.description
|
|
306
|
+
if testcase.allure:
|
|
307
|
+
summary["allure"] = testcase.allure.model_dump(exclude_none=True)
|
|
308
|
+
if testcase.teardowns:
|
|
309
|
+
summary["teardowns"] = [
|
|
310
|
+
{"id": td.id, "operation_type": td.operation_type}
|
|
311
|
+
for td in testcase.teardowns
|
|
312
|
+
]
|
|
313
|
+
return summary
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def format_validation_error(exc: ValidationError) -> list[str]:
|
|
317
|
+
"""格式化 Pydantic 验证错误"""
|
|
318
|
+
return [
|
|
319
|
+
f"{'.'.join(str(l) for l in err['loc'])}: {err['msg']} (类型: {err.get('type', 'unknown')})"
|
|
320
|
+
for err in exc.errors()
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def detect_testcase_type(data: dict) -> Literal["unittest", "testcase"]:
|
|
325
|
+
"""检测测试用例类型"""
|
|
326
|
+
if "unittest" in data:
|
|
327
|
+
return "unittest"
|
|
328
|
+
elif "testcase" in data:
|
|
329
|
+
return "testcase"
|
|
330
|
+
raise ValueError("未知的测试用例格式: YAML 文件既不是 unittest 也不是 testcase 格式")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def truncate_text(text: str, max_length: int = MAX_ERROR_LENGTH) -> str:
|
|
334
|
+
"""截断文本到指定长度"""
|
|
335
|
+
if not text:
|
|
336
|
+
return ""
|
|
337
|
+
return text[-max_length:] if len(text) > max_length else text
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def contains_chinese(text: str) -> bool:
|
|
341
|
+
"""检测字符串是否包含中文字符"""
|
|
342
|
+
if not text:
|
|
343
|
+
return False
|
|
344
|
+
return bool(re.search(r'[\u4e00-\u9fff]', str(text)))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = [
|
|
348
|
+
"REPO_ROOT",
|
|
349
|
+
"TESTS_ROOT",
|
|
350
|
+
"CASES_ROOT",
|
|
351
|
+
"SCRIPTS_ROOT",
|
|
352
|
+
"TEST_CASES_ROOT", # 兼容旧版本
|
|
353
|
+
"PYTEST_TIMEOUT",
|
|
354
|
+
"MAX_ERROR_LENGTH",
|
|
355
|
+
"MAX_HISTORY_SIZE",
|
|
356
|
+
"MCP_LOGS_ROOT",
|
|
357
|
+
"MCP_CALLS_LOG",
|
|
358
|
+
"yaml",
|
|
359
|
+
"new_request_id",
|
|
360
|
+
"build_error_payload",
|
|
361
|
+
"log_tool_call",
|
|
362
|
+
"get_roots",
|
|
363
|
+
"resolve_yaml_path",
|
|
364
|
+
"resolve_tests_root",
|
|
365
|
+
"expected_py_path",
|
|
366
|
+
"load_yaml_file",
|
|
367
|
+
"build_testcase_yaml",
|
|
368
|
+
"build_unittest_yaml",
|
|
369
|
+
"parse_testcase_input",
|
|
370
|
+
"parse_unittest_input",
|
|
371
|
+
"build_testcase_summary",
|
|
372
|
+
"format_validation_error",
|
|
373
|
+
"detect_testcase_type",
|
|
374
|
+
"truncate_text",
|
|
375
|
+
"contains_chinese",
|
|
376
|
+
]
|