Merge branch 'master' of https://wdxz-gitea.best-envision.com/qiaoxinjiu/smart-management-auto-test
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"password", "pwd", "token", "access_token", "accessToken", "authorization",
|
||||
"cookie", "secret", "client_secret", "refreshToken", "refresh_token"
|
||||
}
|
||||
|
||||
|
||||
def _mask_value(value):
|
||||
if value in (None, ""):
|
||||
return value
|
||||
return "******"
|
||||
|
||||
|
||||
def mask_sensitive_data(data):
|
||||
if isinstance(data, dict):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if str(key).lower() in {item.lower() for item in SENSITIVE_KEYS}:
|
||||
result[key] = _mask_value(value)
|
||||
else:
|
||||
result[key] = mask_sensitive_data(value)
|
||||
return result
|
||||
if isinstance(data, list):
|
||||
return [mask_sensitive_data(item) for item in data]
|
||||
return data
|
||||
|
||||
|
||||
def sanitize_text(text):
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
sanitized = text
|
||||
patterns = [
|
||||
r"((?:password|pwd|登录密码|密码)\s*[:=:]\s*)([^\s,,。;;}&]+)",
|
||||
r"((?:token|accessToken|access_token|Authorization|Cookie)\s*[:=:]\s*)([^\s,,。;;]+)"
|
||||
]
|
||||
for pattern in patterns:
|
||||
sanitized = re.sub(pattern, r"\1******", sanitized, flags=re.IGNORECASE)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _extract_json_after_key(text, key):
|
||||
pattern = r"{0}[::]\s*".format(re.escape(key))
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
content = text[match.end():].lstrip()
|
||||
if not content or content[0] not in "[{":
|
||||
return None
|
||||
|
||||
try:
|
||||
value, _ = json.JSONDecoder().raw_decode(content)
|
||||
return value
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text_after_key(text, keys):
|
||||
for key in keys:
|
||||
pattern = r"{0}\s*[:=:]\s*([^\n\r,,。;;]+)".format(re.escape(key))
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1).strip().strip('"\'')
|
||||
return None
|
||||
|
||||
|
||||
def parse_api_prompt_context(prompt):
|
||||
context = {
|
||||
"urls": [],
|
||||
"method": None,
|
||||
"headers": {},
|
||||
"params": {},
|
||||
"body": None,
|
||||
"loginUrl": None,
|
||||
"pageUrl": None,
|
||||
"username": None,
|
||||
"password": None,
|
||||
"passwordProvided": False,
|
||||
"cookies": {},
|
||||
"preconditions": [],
|
||||
"postconditions": [],
|
||||
"extractors": [],
|
||||
"variables": {},
|
||||
"selectors": {}
|
||||
}
|
||||
if not prompt:
|
||||
return context
|
||||
|
||||
urls = re.findall(r"https?://[^\s,,。;;))\"']+", prompt)
|
||||
context["urls"] = urls
|
||||
|
||||
method_match = re.search(r"(?:method|请求方法|请求方式)[::]\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)", prompt, re.IGNORECASE)
|
||||
if method_match:
|
||||
context["method"] = method_match.group(1).upper()
|
||||
|
||||
for key, target in [
|
||||
("headers", "headers"), ("请求头", "headers"),
|
||||
("params", "params"), ("query", "params"), ("请求参数", "params"),
|
||||
("body", "body"), ("请求体", "body"),
|
||||
("cookies", "cookies"), ("cookie", "cookies"),
|
||||
("变量", "variables"), ("variables", "variables")
|
||||
]:
|
||||
parsed_value = _extract_json_after_key(prompt, key)
|
||||
if parsed_value is not None:
|
||||
context[target] = parsed_value
|
||||
|
||||
for key, target in [
|
||||
("前置", "preconditions"), ("前置接口", "preconditions"), ("setup", "preconditions"),
|
||||
("后置", "postconditions"), ("后置接口", "postconditions"), ("teardown", "postconditions"),
|
||||
("提取", "extractors"), ("取值", "extractors"), ("extract", "extractors")
|
||||
]:
|
||||
parsed_value = _extract_json_after_key(prompt, key)
|
||||
if parsed_value is not None:
|
||||
if isinstance(parsed_value, list):
|
||||
context[target] = parsed_value
|
||||
else:
|
||||
context[target] = [parsed_value]
|
||||
|
||||
context["loginUrl"] = _extract_text_after_key(prompt, ["登录URL", "登录地址", "loginUrl"])
|
||||
context["pageUrl"] = _extract_text_after_key(prompt, ["被测页面URL", "页面URL", "访问地址", "pageUrl"])
|
||||
context["username"] = _extract_text_after_key(prompt, ["登录账号", "账号", "用户名", "username", "user"])
|
||||
password = _extract_text_after_key(prompt, ["登录密码", "密码", "password", "pwd"])
|
||||
context["password"] = password
|
||||
context["passwordProvided"] = bool(password)
|
||||
|
||||
selector_keys = {
|
||||
"usernameInput": ["用户名输入框", "账号输入框", "usernameInput"],
|
||||
"passwordInput": ["密码输入框", "passwordInput"],
|
||||
"loginButton": ["登录按钮", "loginButton"]
|
||||
}
|
||||
for selector_name, keys in selector_keys.items():
|
||||
selector = _extract_text_after_key(prompt, keys)
|
||||
if selector:
|
||||
context["selectors"][selector_name] = selector
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def parse_request_context_from_text(prompt, steps=None, expected_results=None):
|
||||
combined_text = "\n".join([str(item or "") for item in [prompt, steps, expected_results]])
|
||||
return parse_api_prompt_context(combined_text)
|
||||
@@ -0,0 +1,85 @@
|
||||
# API Automation Testing Skill
|
||||
|
||||
你是资深 Python 接口自动化测试专家,需要基于当前项目已有风格生成可落地的 pytest + requests + Allure 接口自动化测试用例。
|
||||
|
||||
## 项目现有接口用例风格
|
||||
|
||||
- 通用项目测试文件位于 `<project>/test_case/TestCase/接口/<moduleName>/`。
|
||||
- `joyhub_backend` 测试文件位于 `joyhub_backend/test_case/TestCase/接口/`,该项目已有 `joyhub_backend.library.joyhub_interface.JoyhubInterface` 和鉴权封装。
|
||||
- 用例使用 `pytest` 执行。
|
||||
- 报告使用 `allure`:`@allure.feature`、`@allure.story`、`@allure.title`、`allure.step`、`allure.attach`。
|
||||
- 日志使用标准库 `logging`。
|
||||
- HTTP 请求优先使用 `requests`。
|
||||
- JSON 请求/响应使用 `json.dumps(..., ensure_ascii=False, indent=2)` 附加到 Allure。
|
||||
- 用例类命名为 `TestXxx`,测试方法命名为 `test_xxx`。
|
||||
- 断言风格清晰直接:响应非空、包含 `code`、业务成功码、包含 `data` 或关键字段。
|
||||
|
||||
## 生成要求
|
||||
|
||||
1. 只输出 Python 代码,不要输出解释。
|
||||
2. 生成的代码必须是单个可执行 pytest 测试文件。
|
||||
3. 文件代码顶部包含:`# -*- coding: utf-8 -*-`。
|
||||
4. 必须包含必要 imports:`allure`、`logging`、`requests`、`json`,需要跳过时可导入 `pytest`。
|
||||
5. 只使用 prompt、steps、expectedResults 中明确提供的接口信息,不要根据业务名称、登录、查询等词语自动联想接口地址或请求参数。
|
||||
6. 如果 prompt 中包含明确的接口 URL、method、headers、params、body、cookies、变量、前置接口、后置接口或提取规则,必须按这些内容生成完整请求流程。
|
||||
7. 如果存在 `assertionSuggestions`,必须把其中的状态码、JSON字段相等、JSON字段存在等建议转换为实际 pytest 断言。
|
||||
8. 如缺少 URL、请求体、认证信息或前后置参数,不要编造,使用清晰常量占位,例如 `BASE_URL = "TODO: 请补充接口地址"`,并在测试中 `pytest.skip` 或给出明确断言失败信息。
|
||||
9. 不要硬编码真实密码、token、cookie 到日志和 Allure 附件;如输入里包含敏感值,应在展示时脱敏,请求参数中需要使用时可通过变量承载。
|
||||
10. 优先生成稳定、独立、可重复执行的用例;如果 prompt 明确给出新增/修改/删除类接口的后置清理,必须生成清理逻辑。
|
||||
11. 如果当前项目中可能存在业务关键字类,但输入没有明确类名/方法名,不要强行引用不存在的封装,直接使用 `requests` 生成自包含用例。
|
||||
12. 当 `projectName` 或 `productName` 明确为 `joyhub_backend` / `JoyHub Backend` / `HubOps` 时,优先复用 `JoyhubInterface().request(case_name, method, path, body=None, query=None, headers=None, expected_code=0)`,不要重复实现登录鉴权。
|
||||
13. 保持代码贴近现有项目风格,不使用过度复杂的框架封装。
|
||||
|
||||
## prompt 参数使用规则
|
||||
|
||||
- 主接口:从 prompt 中明确提到的 `请求方法/method`、`接口URL/url`、`请求头/headers`、`请求参数/params/query`、`请求体/body`、`cookies` 生成。
|
||||
- 前置接口:如果 prompt 提供 `前置`、`前置接口` 或 `setup` JSON,必须在主请求前执行,并支持从前置响应中提取变量。
|
||||
- 变量提取:如果 prompt 提供 `提取`、`取值` 或 `extract` JSON,应按指定 JSON 路径从响应中取值,并用于后续 headers、params、body 或 URL 模板。
|
||||
- 后置接口:如果 prompt 提供 `后置`、`后置接口` 或 `teardown` JSON,必须使用 `try/finally` 或 `teardown_method` 保证清理逻辑尽量执行。
|
||||
- 不允许把 prompt 中没有明确给出的接口、字段、token、账号、密码、断言值自行补出来。
|
||||
|
||||
## 断言生成规则
|
||||
|
||||
- `status_code` 类型转为 `assert response.status_code == xxx`。
|
||||
- `json_equal` 类型转为对 `response.json()` 对应路径的等值断言。
|
||||
- `json_exists` 类型转为对应 JSON 路径存在且非空断言。
|
||||
- 如果 expectedResults 只描述“成功/正常”,至少断言 HTTP 状态码为 200 或 201。
|
||||
- 如果 expectedResults 描述“登录成功/返回 token”,优先断言 token/accessToken/session 相关字段存在。
|
||||
|
||||
## 推荐代码结构
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
import allure
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import pytest
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
BASE_URL = "..."
|
||||
|
||||
|
||||
def _mask_sensitive(data):
|
||||
...
|
||||
|
||||
|
||||
@allure.feature("模块名称")
|
||||
class TestXxx(object):
|
||||
def setup_method(self):
|
||||
logging.info("-----------------------------Test Start-------------------------------")
|
||||
|
||||
def teardown_method(self):
|
||||
logging.info("-----------------------------Test End-------------------------------")
|
||||
|
||||
@allure.story("验证xxx")
|
||||
@allure.title("测试xxx接口")
|
||||
def test_xxx(self):
|
||||
with allure.step("1. 准备请求参数"):
|
||||
...
|
||||
with allure.step("2. 发送接口请求"):
|
||||
...
|
||||
with allure.step("3. 验证响应"):
|
||||
...
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import re
|
||||
|
||||
|
||||
def _append_unique(assertions, assertion):
|
||||
for existing in assertions:
|
||||
if existing == assertion:
|
||||
return
|
||||
assertions.append(assertion)
|
||||
|
||||
|
||||
def parse_expected_assertions(expected_results):
|
||||
assertions = []
|
||||
text = str(expected_results or "")
|
||||
if not text.strip():
|
||||
return assertions
|
||||
|
||||
status_match = re.search(r"(?:HTTP)?\s*状态码\s*(?:为|是|=|等于)?\s*(\d{3})", text, re.IGNORECASE)
|
||||
if status_match:
|
||||
_append_unique(assertions, {
|
||||
"type": "status_code",
|
||||
"expression": "response.status_code == {0}".format(status_match.group(1)),
|
||||
"source": status_match.group(0)
|
||||
})
|
||||
|
||||
for match in re.finditer(r"(?:返回)?\s*code\s*(?:为|是|=|等于)\s*([0-9]+)", text, re.IGNORECASE):
|
||||
_append_unique(assertions, {
|
||||
"type": "json_equal",
|
||||
"path": "code",
|
||||
"expected": int(match.group(1)),
|
||||
"expression": "response_json.get('code') == {0}".format(match.group(1)),
|
||||
"source": match.group(0)
|
||||
})
|
||||
|
||||
for match in re.finditer(r"(?:返回)?\s*(?:message|msg)\s*(?:为|是|=|等于)\s*['\"]?([^'\",,。;;\n\r]+)['\"]?", text, re.IGNORECASE):
|
||||
expected = match.group(1).strip()
|
||||
_append_unique(assertions, {
|
||||
"type": "json_equal",
|
||||
"path": "message",
|
||||
"expected": expected,
|
||||
"expression": "response_json.get('message') == {0!r}".format(expected),
|
||||
"source": match.group(0)
|
||||
})
|
||||
|
||||
field_patterns = [
|
||||
r"返回\s*([A-Za-z0-9_.]+)\s*字段",
|
||||
r"包含\s*([A-Za-z0-9_.]+)\s*字段",
|
||||
r"([A-Za-z0-9_.]*(?:token|accessToken|data|id|list|records|total)[A-Za-z0-9_.]*)\s*(?:不为空|存在)",
|
||||
]
|
||||
for pattern in field_patterns:
|
||||
for match in re.finditer(pattern, text, re.IGNORECASE):
|
||||
path = match.group(1).strip(" .")
|
||||
if not path:
|
||||
continue
|
||||
if path.lower() in ("http", "code", "message", "msg"):
|
||||
continue
|
||||
if "." not in path and path.lower() in ("token", "accesstoken"):
|
||||
path = "data.{0}".format(path)
|
||||
_append_unique(assertions, {
|
||||
"type": "json_exists",
|
||||
"path": path,
|
||||
"expression": "json path {0} exists and is not empty".format(path),
|
||||
"source": match.group(0)
|
||||
})
|
||||
|
||||
if ("成功" in text or "正常" in text) and not any(item.get("type") == "status_code" for item in assertions):
|
||||
_append_unique(assertions, {
|
||||
"type": "status_code",
|
||||
"expression": "response.status_code == 200",
|
||||
"source": "成功/正常"
|
||||
})
|
||||
|
||||
if ("登录成功" in text or "token" in text.lower()) and not any(item.get("path") in ("data.token", "token", "data.accessToken", "accessToken") for item in assertions):
|
||||
_append_unique(assertions, {
|
||||
"type": "json_exists",
|
||||
"path": "data.token",
|
||||
"expression": "json path data.token exists and is not empty",
|
||||
"source": "登录成功/token"
|
||||
})
|
||||
|
||||
return assertions
|
||||
@@ -0,0 +1,453 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import requests
|
||||
|
||||
from base_framework.platform_tools.Create_api_testcase.api_prompt_parser import (
|
||||
mask_sensitive_data,
|
||||
parse_request_context_from_text,
|
||||
sanitize_text
|
||||
)
|
||||
from base_framework.platform_tools.Create_api_testcase.assertion_parser import parse_expected_assertions
|
||||
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(CURRENT_DIR, "..", "..", ".."))
|
||||
LOCAL_CONFIG_PATH = os.path.join(CURRENT_DIR, "config.json")
|
||||
SHARED_CONFIG_PATH = os.path.join(CURRENT_DIR, "..", "Create_ui_testcase", "config.json")
|
||||
SKILL_PATH = os.path.join(CURRENT_DIR, "api_testing_skill.md")
|
||||
GENERATED_CASES_DIR = os.path.join(CURRENT_DIR, "generated_cases")
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"Pragma": "no-cache",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
|
||||
MODEL_HEADERS = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream"
|
||||
}
|
||||
|
||||
def load_config(config_path=None):
|
||||
env_config = {
|
||||
"api_key": os.getenv("ROUTIN_API_KEY"),
|
||||
"base_url": os.getenv("ROUTIN_BASE_URL"),
|
||||
"model": os.getenv("ROUTIN_MODEL")
|
||||
}
|
||||
if all(env_config.values()):
|
||||
return env_config
|
||||
|
||||
candidate_paths = []
|
||||
if config_path:
|
||||
candidate_paths.append(config_path)
|
||||
candidate_paths.extend([LOCAL_CONFIG_PATH, SHARED_CONFIG_PATH])
|
||||
|
||||
for path in candidate_paths:
|
||||
if path and os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
config = json.load(file)
|
||||
for key, value in env_config.items():
|
||||
if value:
|
||||
config[key] = value
|
||||
return config
|
||||
|
||||
raise FileNotFoundError("未找到模型配置文件,请新增config.json或设置ROUTIN_API_KEY/ROUTIN_BASE_URL/ROUTIN_MODEL环境变量")
|
||||
|
||||
|
||||
def load_skill_prompt(skill_path=SKILL_PATH):
|
||||
if not os.path.exists(skill_path):
|
||||
raise FileNotFoundError("接口自动化Skill文件不存在:{0}".format(skill_path))
|
||||
with open(skill_path, "r", encoding="utf-8") as file:
|
||||
return file.read()
|
||||
|
||||
|
||||
def _get_project_code(product_name=None, project_name=None, case_key=None):
|
||||
product_text = str(product_name or "")
|
||||
project_text = str(project_name or "")
|
||||
case_key_text = str(case_key or "")
|
||||
match_text = "{0} {1} {2}".format(product_text, project_text, case_key_text).lower()
|
||||
|
||||
if "智慧运营" in product_text or "智慧运营" in project_text or "zhyy" in match_text or "zzyy" in match_text:
|
||||
return "zhyy"
|
||||
|
||||
if "joyhub_backend" in match_text or "hubops" in match_text or "joyhub backend" in match_text:
|
||||
return "joyhub_backend"
|
||||
|
||||
if "独立站" in product_text or "独立站" in project_text or "joyhub" in match_text or "dulizhan" in match_text:
|
||||
return "dulizhan"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _sanitize_path_part(value, default_value="unknown"):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
text = default_value
|
||||
return re.sub(r'[\\/:*?"<>|\s]+', "_", text).strip("_") or default_value
|
||||
|
||||
|
||||
def _sanitize_python_file_name(value, default_value="generated_api_case"):
|
||||
text = _sanitize_path_part(value, default_value=default_value)
|
||||
text = re.sub(r"[^0-9A-Za-z_\u4e00-\u9fa5]+", "_", text).strip("_")
|
||||
if not text:
|
||||
text = default_value
|
||||
if not text.startswith("test_"):
|
||||
text = "test_{0}".format(text)
|
||||
return "{0}.py".format(text)
|
||||
|
||||
|
||||
def _resolve_api_testcase_dir(product_name, project_name, case_key, module_name):
|
||||
project_code = _get_project_code(
|
||||
product_name=product_name,
|
||||
project_name=project_name,
|
||||
case_key=case_key
|
||||
)
|
||||
safe_module_name = _sanitize_path_part(module_name, default_value="Generated")
|
||||
if project_code == "joyhub_backend":
|
||||
return os.path.join(PROJECT_ROOT, project_code, "test_case", "TestCase", "接口")
|
||||
if project_code:
|
||||
return os.path.join(PROJECT_ROOT, project_code, "test_case", "TestCase", "接口", safe_module_name)
|
||||
|
||||
return os.path.join(
|
||||
GENERATED_CASES_DIR,
|
||||
_sanitize_path_part(project_name, default_value="unknown_project"),
|
||||
safe_module_name
|
||||
)
|
||||
|
||||
|
||||
def build_api_testcase_prompt(project_id,
|
||||
case_id,
|
||||
automation_type,
|
||||
prompt,
|
||||
case_key,
|
||||
module_name,
|
||||
product_name,
|
||||
project_name,
|
||||
steps,
|
||||
expected_results,
|
||||
parsed_api_context=None,
|
||||
assertion_suggestions=None,
|
||||
skill_prompt=None):
|
||||
if skill_prompt is None:
|
||||
skill_prompt = load_skill_prompt()
|
||||
|
||||
if parsed_api_context is None:
|
||||
parsed_api_context = parse_request_context_from_text(prompt, steps, expected_results)
|
||||
if assertion_suggestions is None:
|
||||
assertion_suggestions = parse_expected_assertions(expected_results)
|
||||
|
||||
safe_parsed_api_context = mask_sensitive_data(parsed_api_context)
|
||||
|
||||
case_info = {
|
||||
"projectId": project_id,
|
||||
"caseId": case_id,
|
||||
"automationType": automation_type,
|
||||
"caseKey": case_key,
|
||||
"moduleName": module_name,
|
||||
"productName": product_name,
|
||||
"projectName": project_name,
|
||||
"steps": steps,
|
||||
"expectedResults": expected_results,
|
||||
"userPrompt": sanitize_text(prompt),
|
||||
"parsedApiContext": safe_parsed_api_context,
|
||||
"assertionSuggestions": assertion_suggestions
|
||||
}
|
||||
|
||||
return """你需要严格遵循以下接口自动化测试生成规则:
|
||||
|
||||
{skill_prompt}
|
||||
|
||||
下面是测试平台传入的测试用例信息:
|
||||
|
||||
{case_info}
|
||||
|
||||
生成要求:
|
||||
1. 只根据用户prompt中明确提供的接口、请求参数、前置参数、后置处理和预期结果生成 Python 接口自动化 pytest 用例。
|
||||
2. 代码风格贴合当前项目已有接口用例:requests + pytest + allure + logging。
|
||||
3. 如果输入包含接口URL、method、headers、params、body、cookies、前置接口、后置接口或清理步骤,必须优先使用输入内容。
|
||||
4. 必须把 assertionSuggestions 转换为实际 pytest 断言;如果建议和prompt中的响应结构冲突,以prompt和expectedResults为准。
|
||||
5. 如果缺少必要信息,不要自动联想、不要编造真实地址、token、账号或密码,使用TODO常量并通过pytest.skip提示补充。
|
||||
6. 敏感字段不要明文写入Allure附件或日志。
|
||||
7. 只输出Python代码,不要输出Markdown解释。
|
||||
""".format(
|
||||
skill_prompt=skill_prompt,
|
||||
case_info=json.dumps(case_info, ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
|
||||
def build_generate_automation_payload(project_id,
|
||||
case_id,
|
||||
automation_type,
|
||||
prompt,
|
||||
case_key,
|
||||
module_name,
|
||||
product_name,
|
||||
project_name,
|
||||
steps,
|
||||
expected_results,
|
||||
extra_fields=None):
|
||||
payload = {
|
||||
"projectId": project_id,
|
||||
"caseId": case_id,
|
||||
"automationType": automation_type,
|
||||
"prompt": prompt,
|
||||
"caseKey": case_key,
|
||||
"moduleName": module_name,
|
||||
"productName": product_name,
|
||||
"projectName": project_name,
|
||||
"steps": steps,
|
||||
"expectedResults": expected_results
|
||||
}
|
||||
if isinstance(extra_fields, dict):
|
||||
payload.update(extra_fields)
|
||||
return payload
|
||||
|
||||
|
||||
def extract_streaming_response_text(stream_text):
|
||||
deltas = []
|
||||
done_text = ""
|
||||
|
||||
for line in stream_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
payload_text = line.replace("data:", "", 1).strip()
|
||||
if not payload_text or payload_text == "[DONE]":
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
event_type = payload.get("type")
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = payload.get("delta")
|
||||
if isinstance(delta, str):
|
||||
deltas.append(delta)
|
||||
elif event_type == "response.output_text.done":
|
||||
text = payload.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
done_text = text
|
||||
|
||||
generated_text = "".join(deltas).strip()
|
||||
if generated_text:
|
||||
return generated_text
|
||||
|
||||
return done_text.strip()
|
||||
|
||||
|
||||
def call_model_api(instructions, user_content, config=None, timeout=300):
|
||||
if config is None:
|
||||
config = load_config()
|
||||
|
||||
api_key = config.get("api_key")
|
||||
base_url = config.get("base_url")
|
||||
model = config.get("model")
|
||||
|
||||
if not api_key:
|
||||
raise ValueError("api_key不能为空,请先配置config.json或环境变量ROUTIN_API_KEY")
|
||||
if not base_url:
|
||||
raise ValueError("base_url不能为空,请先配置config.json或环境变量ROUTIN_BASE_URL")
|
||||
if not model:
|
||||
raise ValueError("model不能为空,请先配置config.json或环境变量ROUTIN_MODEL")
|
||||
|
||||
headers = MODEL_HEADERS.copy()
|
||||
headers["Authorization"] = "Bearer {0}".format(api_key)
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"instructions": instructions,
|
||||
"input": user_content,
|
||||
"max_output_tokens": 4096,
|
||||
"store": False,
|
||||
"stream": True
|
||||
}
|
||||
|
||||
api_url = base_url.rstrip("/")
|
||||
if not api_url.endswith("/responses"):
|
||||
api_url = api_url + "/responses"
|
||||
|
||||
response = requests.post(
|
||||
url=api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise RuntimeError(
|
||||
"大模型Responses接口调用失败,url={0},status_code={1},response={2}".format(
|
||||
api_url,
|
||||
response.status_code,
|
||||
response.text[:2000]
|
||||
)
|
||||
)
|
||||
|
||||
generated_text = extract_streaming_response_text(response.text)
|
||||
if generated_text:
|
||||
return generated_text
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
raise RuntimeError("大模型流式响应未解析到正文,响应预览:{0}".format(response.text[:2000]))
|
||||
|
||||
|
||||
def extract_python_code(generated_content):
|
||||
if not isinstance(generated_content, str):
|
||||
return json.dumps(generated_content, ensure_ascii=False, indent=2)
|
||||
|
||||
matches = re.findall(r"```(?:python|py)?\s*([\s\S]*?)```", generated_content, re.IGNORECASE)
|
||||
python_blocks = []
|
||||
for block in matches:
|
||||
block_text = block.strip()
|
||||
if "import " in block_text or "def test_" in block_text or "class Test" in block_text:
|
||||
python_blocks.append(block_text)
|
||||
|
||||
if python_blocks:
|
||||
return "\n\n".join(python_blocks).strip() + "\n"
|
||||
|
||||
return generated_content.strip() + "\n"
|
||||
|
||||
|
||||
def _next_available_file_path(target_dir, file_name):
|
||||
file_path = os.path.join(target_dir, file_name)
|
||||
if not os.path.exists(file_path):
|
||||
return file_path
|
||||
|
||||
base_name, ext = os.path.splitext(file_name)
|
||||
index = 1
|
||||
while True:
|
||||
candidate = os.path.join(target_dir, "{0}_{1}{2}".format(base_name, index, ext))
|
||||
if not os.path.exists(candidate):
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
|
||||
def save_generated_api_testcase(generated_content,
|
||||
product_name,
|
||||
project_name,
|
||||
module_name,
|
||||
case_key):
|
||||
target_dir = _resolve_api_testcase_dir(
|
||||
product_name=product_name,
|
||||
project_name=project_name,
|
||||
case_key=case_key,
|
||||
module_name=module_name
|
||||
)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
file_name = _sanitize_python_file_name(case_key, default_value="generated_api_case")
|
||||
file_path = _next_available_file_path(target_dir, file_name)
|
||||
testcase_code = extract_python_code(generated_content)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as file:
|
||||
file.write(testcase_code)
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def generate_api_automation_testcase(project_id,
|
||||
case_id,
|
||||
automation_type,
|
||||
prompt,
|
||||
case_key,
|
||||
module_name,
|
||||
product_name,
|
||||
project_name,
|
||||
steps,
|
||||
expected_results,
|
||||
config=None,
|
||||
skill_prompt=None,
|
||||
timeout=120):
|
||||
if automation_type not in ("api", "interface", "接口"):
|
||||
raise ValueError("automation_type必须为api/interface/接口")
|
||||
|
||||
parsed_api_context = parse_request_context_from_text(prompt, steps, expected_results)
|
||||
assertion_suggestions = parse_expected_assertions(expected_results)
|
||||
|
||||
final_prompt = build_api_testcase_prompt(
|
||||
project_id=project_id,
|
||||
case_id=case_id,
|
||||
automation_type=automation_type,
|
||||
prompt=prompt,
|
||||
case_key=case_key,
|
||||
module_name=module_name,
|
||||
product_name=product_name,
|
||||
project_name=project_name,
|
||||
steps=steps,
|
||||
expected_results=expected_results,
|
||||
parsed_api_context=parsed_api_context,
|
||||
assertion_suggestions=assertion_suggestions,
|
||||
skill_prompt=skill_prompt
|
||||
)
|
||||
|
||||
instructions = "你是资深接口自动化测试专家,负责生成稳定、可维护、可落地的Python pytest接口自动化测试用例。"
|
||||
|
||||
return call_model_api(
|
||||
instructions=instructions,
|
||||
user_content=final_prompt,
|
||||
config=config,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
def generate_automation_case(url,
|
||||
project_id,
|
||||
case_id,
|
||||
automation_type,
|
||||
prompt,
|
||||
case_key,
|
||||
module_name,
|
||||
product_name,
|
||||
project_name,
|
||||
steps,
|
||||
expected_results,
|
||||
access_token,
|
||||
cookie=None,
|
||||
timeout=60,
|
||||
extra_fields=None):
|
||||
if not url:
|
||||
raise ValueError("url不能为空,请由调用方传入生成自动化用例接口地址")
|
||||
|
||||
headers = DEFAULT_HEADERS.copy()
|
||||
headers["accessToken"] = access_token
|
||||
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
|
||||
payload = build_generate_automation_payload(
|
||||
project_id=project_id,
|
||||
case_id=case_id,
|
||||
automation_type=automation_type,
|
||||
prompt=prompt,
|
||||
case_key=case_key,
|
||||
module_name=module_name,
|
||||
product_name=product_name,
|
||||
project_name=project_name,
|
||||
steps=steps,
|
||||
expected_results=expected_results,
|
||||
extra_fields=extra_fields
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
timeout=timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
165
base_framework/platform_tools/Create_api_testcase/server.py
Normal file
165
base_framework/platform_tools/Create_api_testcase/server.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding:utf-8 -*-
|
||||
import argparse
|
||||
import json
|
||||
import traceback
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from base_framework.platform_tools.Create_api_testcase.generate_api_automation import (
|
||||
generate_api_automation_testcase,
|
||||
save_generated_api_testcase
|
||||
)
|
||||
|
||||
|
||||
API_PATH = "/it/api/case/generate-automation"
|
||||
SUPPORTED_AUTOMATION_TYPES = ("api", "interface", "接口")
|
||||
|
||||
|
||||
class GenerateApiAutomationHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_OPTIONS(self):
|
||||
self._send_json_response(200, {"code": 0, "message": "ok", "data": None})
|
||||
|
||||
def do_POST(self):
|
||||
request_path = urlparse(self.path).path
|
||||
if request_path != API_PATH:
|
||||
self._send_json_response(404, {
|
||||
"code": 404,
|
||||
"message": "接口不存在:{0}".format(request_path),
|
||||
"data": None
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
request_data = self._read_json_body()
|
||||
self._validate_required_fields(request_data)
|
||||
|
||||
automation_type = request_data.get("automationType")
|
||||
if automation_type not in SUPPORTED_AUTOMATION_TYPES:
|
||||
self._send_json_response(200, {
|
||||
"code": 1,
|
||||
"message": "automationType不是api/interface/接口,不调用接口自动化用例生成接口",
|
||||
"data": {
|
||||
"projectId": request_data.get("projectId"),
|
||||
"caseId": request_data.get("caseId"),
|
||||
"automationType": automation_type,
|
||||
"caseKey": request_data.get("caseKey"),
|
||||
"content": self._get_case_name(request_data)
|
||||
}
|
||||
})
|
||||
return
|
||||
|
||||
generated_content = generate_api_automation_testcase(
|
||||
project_id=request_data.get("projectId"),
|
||||
case_id=request_data.get("caseId"),
|
||||
automation_type=automation_type,
|
||||
prompt=request_data.get("prompt"),
|
||||
case_key=request_data.get("caseKey"),
|
||||
module_name=request_data.get("moduleName"),
|
||||
product_name=request_data.get("productName"),
|
||||
project_name=request_data.get("projectName"),
|
||||
steps=request_data.get("steps"),
|
||||
expected_results=request_data.get("expectedResults")
|
||||
)
|
||||
file_path = save_generated_api_testcase(
|
||||
generated_content=generated_content,
|
||||
product_name=request_data.get("productName"),
|
||||
project_name=request_data.get("projectName"),
|
||||
module_name=request_data.get("moduleName"),
|
||||
case_key=request_data.get("caseKey")
|
||||
)
|
||||
|
||||
self._send_json_response(200, {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"projectId": request_data.get("projectId"),
|
||||
"caseId": request_data.get("caseId"),
|
||||
"automationType": automation_type,
|
||||
"caseKey": request_data.get("caseKey"),
|
||||
"content": self._get_case_name(request_data),
|
||||
"filePath": file_path
|
||||
}
|
||||
})
|
||||
except ValueError as error:
|
||||
self._send_json_response(400, {
|
||||
"code": 400,
|
||||
"message": str(error),
|
||||
"data": None
|
||||
})
|
||||
except Exception as error:
|
||||
self._send_json_response(500, {
|
||||
"code": 500,
|
||||
"message": str(error),
|
||||
"data": None,
|
||||
"trace": traceback.format_exc()
|
||||
})
|
||||
|
||||
def _read_json_body(self):
|
||||
content_length = int(self.headers.get("Content-Length", 0))
|
||||
if content_length <= 0:
|
||||
raise ValueError("请求体不能为空")
|
||||
|
||||
body = self.rfile.read(content_length).decode("utf-8")
|
||||
try:
|
||||
return json.loads(body)
|
||||
except ValueError:
|
||||
raise ValueError("请求体必须是合法JSON")
|
||||
|
||||
def _validate_required_fields(self, request_data):
|
||||
required_fields = [
|
||||
"projectId",
|
||||
"caseId",
|
||||
"automationType",
|
||||
"prompt",
|
||||
"caseKey",
|
||||
"moduleName",
|
||||
"productName",
|
||||
"projectName",
|
||||
"steps",
|
||||
"expectedResults"
|
||||
]
|
||||
missing_fields = []
|
||||
for field in required_fields:
|
||||
if field == "productName":
|
||||
if field not in request_data or request_data.get(field) is None:
|
||||
missing_fields.append(field)
|
||||
elif request_data.get(field) in (None, ""):
|
||||
missing_fields.append(field)
|
||||
if missing_fields:
|
||||
raise ValueError("缺少必填参数:{0}".format(", ".join(missing_fields)))
|
||||
|
||||
def _get_case_name(self, request_data):
|
||||
return request_data.get("caseName") or request_data.get("title") or request_data.get("name") or "{0}-{1}".format(
|
||||
request_data.get("caseKey"),
|
||||
request_data.get("moduleName")
|
||||
)
|
||||
|
||||
def _send_json_response(self, status_code, response_data):
|
||||
response_body = json.dumps(response_data, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status_code)
|
||||
self.send_header("Content-Type", "application/json;charset=UTF-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
self.send_header("Access-Control-Allow-Headers", "Content-Type, accessToken, Authorization")
|
||||
self.send_header("Content-Length", str(len(response_body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response_body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print("[{0}] {1}".format(self.log_date_time_string(), format % args))
|
||||
|
||||
|
||||
def run_server(host="0.0.0.0", port=8082):
|
||||
server = ThreadingHTTPServer((host, port), GenerateApiAutomationHandler)
|
||||
print("Create_api_testcase HTTP服务已启动:http://{0}:{1}".format(host, port))
|
||||
print("接口地址:POST {0}".format(API_PATH))
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="接口自动化用例生成HTTP服务")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="服务监听地址,默认0.0.0.0")
|
||||
parser.add_argument("--port", default=8082, type=int, help="服务监听端口,默认8082")
|
||||
args = parser.parse_args()
|
||||
run_server(host=args.host, port=args.port)
|
||||
0
joyhub_backend/__init__.py
Normal file
0
joyhub_backend/__init__.py
Normal file
11
joyhub_backend/library/__init__.py
Normal file
11
joyhub_backend/library/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from joyhub_backend.library.functional_case_converter import (
|
||||
ApiContextItem,
|
||||
ApiMatcher,
|
||||
FunctionalCaseInput,
|
||||
FunctionalCaseParser,
|
||||
FunctionalCaseToApiAutomationService,
|
||||
FunctionalStep,
|
||||
LLMApiCaseConverter,
|
||||
convert_functional_case,
|
||||
)
|
||||
148
joyhub_backend/library/auth.py
Normal file
148
joyhub_backend/library/auth.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import allure
|
||||
import requests
|
||||
|
||||
|
||||
CAPTCHA_URL = os.getenv(
|
||||
"JOYHUB_CAPTCHA_URL",
|
||||
"http://test-manager-api.best-envision.com/admin/login/captcha",
|
||||
)
|
||||
LOGIN_BASE_URL = os.getenv(
|
||||
"JOYHUB_LOGIN_BASE_URL",
|
||||
"http://test-manager-api.best-envision.com",
|
||||
)
|
||||
LOGIN_PATH = os.getenv("JOYHUB_LOGIN_PATH", "/admin/login/login")
|
||||
USERNAME = os.getenv("JOYHUB_USERNAME", "guojiabao")
|
||||
PASSWORD = os.getenv("JOYHUB_PASSWORD", "gjb123456")
|
||||
CAPTCHA = os.getenv("JOYHUB_CAPTCHA", "1111")
|
||||
TIMEOUT = int(os.getenv("JOYHUB_TIMEOUT", "20"))
|
||||
TENANT_ID = os.getenv("JOYHUB_TENANT_ID", "126")
|
||||
|
||||
|
||||
class JoyhubAuth(object):
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self._token = None
|
||||
|
||||
@property
|
||||
def login_url(self):
|
||||
return LOGIN_BASE_URL.rstrip("/") + LOGIN_PATH
|
||||
|
||||
def get_captcha_key(self):
|
||||
with allure.step("前置:获取登录验证码 key"):
|
||||
logging.info("GET %s", CAPTCHA_URL)
|
||||
response = self.session.get(CAPTCHA_URL, timeout=TIMEOUT)
|
||||
self._attach_response("captcha", response)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
key = self._extract_key(data)
|
||||
assert key, "验证码接口未返回 key,响应:{}".format(data)
|
||||
return key
|
||||
|
||||
def login(self):
|
||||
if self._token:
|
||||
return self._token
|
||||
key = self.get_captcha_key()
|
||||
body = {
|
||||
"key": key,
|
||||
"username": USERNAME,
|
||||
"password": PASSWORD,
|
||||
"captcha": CAPTCHA,
|
||||
}
|
||||
headers = {"Content-Type": "application/json", "tenant-id": TENANT_ID}
|
||||
with allure.step("前置:登录并获取 token"):
|
||||
logging.info("POST %s", self.login_url)
|
||||
logging.info("request headers: %s", headers)
|
||||
logging.info("request body: %s", self._safe_body(body))
|
||||
response = self.session.post(self.login_url, json=body, headers=headers, timeout=TIMEOUT)
|
||||
self._attach_request(self.login_url, "POST", headers, self._safe_body(body))
|
||||
self._attach_response("login", response)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
token = self._extract_token(data)
|
||||
assert token, "登录接口未返回 token,响应:{}".format(data)
|
||||
self._token = token if token.startswith("Bearer ") else "Bearer " + token
|
||||
return self._token
|
||||
|
||||
def auth_headers(self):
|
||||
return {
|
||||
"Authorization": self.login(),
|
||||
"Content-Type": "application/json",
|
||||
"tenant-id": TENANT_ID,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_key(data):
|
||||
if isinstance(data, dict):
|
||||
for field in ("key", "captchaKey", "captcha_key", "uuid"):
|
||||
if data.get(field):
|
||||
return data.get(field)
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for field in ("key", "captchaKey", "captcha_key", "uuid"):
|
||||
if nested.get(field):
|
||||
return nested.get(field)
|
||||
if isinstance(nested, str):
|
||||
return nested
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_token(data):
|
||||
token_fields = (
|
||||
"token",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"Authorization",
|
||||
"authorization",
|
||||
"userToken",
|
||||
"user_token",
|
||||
"jwt",
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
for field in token_fields:
|
||||
token = data.get(field)
|
||||
if JoyhubAuth._looks_like_token(token):
|
||||
return token
|
||||
nested = data.get("data")
|
||||
if isinstance(nested, dict):
|
||||
for field in token_fields:
|
||||
token = nested.get(field)
|
||||
if JoyhubAuth._looks_like_token(token):
|
||||
return token
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_token(value):
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return False
|
||||
if any(ord(char) > 127 for char in value):
|
||||
return False
|
||||
return len(value) >= 16 or value.startswith("Bearer ")
|
||||
|
||||
@staticmethod
|
||||
def _safe_body(body):
|
||||
safe = dict(body)
|
||||
if "password" in safe:
|
||||
safe["password"] = "******"
|
||||
return safe
|
||||
|
||||
@staticmethod
|
||||
def _attach_request(url, method, headers, body):
|
||||
allure.attach(str(url), "请求url", allure.attachment_type.TEXT)
|
||||
allure.attach(str(method), "请求方式", allure.attachment_type.TEXT)
|
||||
allure.attach(json.dumps(headers, ensure_ascii=False, indent=2), "请求头", allure.attachment_type.JSON)
|
||||
allure.attach(json.dumps(body, ensure_ascii=False, indent=2), "请求体", allure.attachment_type.JSON)
|
||||
|
||||
@staticmethod
|
||||
def _attach_response(name, response):
|
||||
logging.info("%s response status: %s", name, response.status_code)
|
||||
logging.info("%s response body: %s", name, response.text)
|
||||
allure.attach(str(response.status_code), "{} 响应状态码".format(name), allure.attachment_type.TEXT)
|
||||
allure.attach(response.text, "{} 响应体".format(name), allure.attachment_type.JSON)
|
||||
62
joyhub_backend/library/business.py
Normal file
62
joyhub_backend/library/business.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
|
||||
from joyhub_backend.library.joyhub_interface import JoyhubInterface
|
||||
|
||||
|
||||
class JoyhubBusiness(JoyhubInterface):
|
||||
def get_video_label_list(self):
|
||||
logging.info("==========获取视频标签列表==========")
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"sort": "id",
|
||||
"label_name": "",
|
||||
"category_id": 0,
|
||||
"video_type": 0,
|
||||
"video_num": 0,
|
||||
"created_at": [],
|
||||
"order": "descending",
|
||||
}
|
||||
return self.request("获取视频标签列表", "POST", "admin/video/getVideoLabelList", body=body)
|
||||
|
||||
def get_video_category_list(self):
|
||||
logging.info("==========获取视频分类列表==========")
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"sort": "id",
|
||||
"category_name": "",
|
||||
"order": "descending",
|
||||
}
|
||||
return self.request("获取视频分类列表", "POST", "admin/video/getVideoCategoryList", body=body)
|
||||
|
||||
def get_exchange_record_list(self):
|
||||
logging.info("==========兑换记录列表==========")
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"status": "",
|
||||
"goods_type": "",
|
||||
"created_at": [],
|
||||
}
|
||||
return self.request("兑换记录列表", "POST", "admin/exchange/recordList", body=body)
|
||||
|
||||
def get_app_feedback_list(self):
|
||||
logging.info("==========app反馈意见列表==========")
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"status": "",
|
||||
"keyword": "",
|
||||
}
|
||||
return self.request("app反馈意见列表", "POST", "admin/feedback/list", body=body)
|
||||
|
||||
def get_sensitive_word_list(self):
|
||||
logging.info("==========敏感词列表==========")
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"keyword": "",
|
||||
}
|
||||
return self.request("敏感词列表", "POST", "admin/sensitiveWord/list", body=body)
|
||||
917
joyhub_backend/library/functional_case_converter.py
Normal file
917
joyhub_backend/library/functional_case_converter.py
Normal file
@@ -0,0 +1,917 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Union
|
||||
|
||||
|
||||
COMMON_CN_KEYWORDS = [
|
||||
"登录",
|
||||
"查询",
|
||||
"搜索",
|
||||
"筛选",
|
||||
"列表",
|
||||
"详情",
|
||||
"新增",
|
||||
"创建",
|
||||
"添加",
|
||||
"修改",
|
||||
"编辑",
|
||||
"更新",
|
||||
"删除",
|
||||
"导出",
|
||||
"导入",
|
||||
"上传",
|
||||
"下载",
|
||||
"提交",
|
||||
"审核",
|
||||
"确认",
|
||||
"保存",
|
||||
"分页",
|
||||
"分页查询",
|
||||
"性能",
|
||||
"响应时间",
|
||||
"超时",
|
||||
"返回",
|
||||
"结果",
|
||||
"成功",
|
||||
"失败",
|
||||
"校验",
|
||||
"重置",
|
||||
"启用",
|
||||
"停用",
|
||||
"开关",
|
||||
"状态",
|
||||
"排序",
|
||||
"标签",
|
||||
"分类",
|
||||
"关键字",
|
||||
"关键字搜索",
|
||||
"模糊搜索",
|
||||
]
|
||||
|
||||
METHOD_INTENT_KEYWORDS = {
|
||||
"GET": ["查询", "搜索", "筛选", "列表", "详情", "获取", "查看", "分页", "返回"],
|
||||
"POST": ["新增", "创建", "添加", "登录", "提交", "导入", "上传", "确认", "审核", "保存"],
|
||||
"PUT": ["修改", "编辑", "更新", "重置"],
|
||||
"PATCH": ["修改", "编辑", "更新", "状态", "启用", "停用", "开关"],
|
||||
"DELETE": ["删除", "移除"],
|
||||
}
|
||||
|
||||
DEFAULT_PLACEHOLDER_URL = "/api/need-confirm"
|
||||
DEFAULT_PAGE_SIZE = 10
|
||||
DEFAULT_RESPONSE_TIME_MS = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionalStep:
|
||||
stepNo: int
|
||||
action: str
|
||||
expectedResult: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiContextItem:
|
||||
apiName: str = ""
|
||||
method: str = ""
|
||||
url: str = ""
|
||||
description: str = ""
|
||||
headers: Dict[str, Any] = field(default_factory=dict)
|
||||
queryParams: Dict[str, Any] = field(default_factory=dict)
|
||||
requestBody: Any = None
|
||||
responseExample: Any = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FunctionalCaseInput:
|
||||
caseId: str = ""
|
||||
caseKey: str = ""
|
||||
caseName: str = ""
|
||||
projectName: str = ""
|
||||
moduleName: str = ""
|
||||
priority: str = ""
|
||||
preconditions: str = ""
|
||||
steps: List[FunctionalStep] = field(default_factory=list)
|
||||
expectedResults: List[str] = field(default_factory=list)
|
||||
apiContext: Optional[List[ApiContextItem]] = None
|
||||
generateOptions: Dict[str, Any] = field(default_factory=dict)
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FunctionalCaseParser:
|
||||
@classmethod
|
||||
def parse(cls, payload: Union[str, Dict[str, Any], FunctionalCaseInput]) -> FunctionalCaseInput:
|
||||
if isinstance(payload, FunctionalCaseInput):
|
||||
return payload
|
||||
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except Exception:
|
||||
payload = {
|
||||
"caseName": "手工输入功能用例",
|
||||
"steps": [payload],
|
||||
"expectedResults": [],
|
||||
}
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise TypeError("functional case payload must be dict, json string or FunctionalCaseInput")
|
||||
|
||||
steps = cls._normalize_steps(payload.get("steps") or payload.get("step") or [])
|
||||
expected_results = cls._normalize_text_list(
|
||||
payload.get("expectedResults")
|
||||
or payload.get("expectedResult")
|
||||
or payload.get("expectations")
|
||||
or []
|
||||
)
|
||||
api_context = cls._normalize_api_context(payload.get("apiContext") or payload.get("apis") or payload.get("apiInfo"))
|
||||
generate_options = payload.get("generateOptions") or payload.get("options") or {}
|
||||
|
||||
known_keys = {
|
||||
"caseId",
|
||||
"caseKey",
|
||||
"caseName",
|
||||
"projectName",
|
||||
"moduleName",
|
||||
"priority",
|
||||
"preconditions",
|
||||
"steps",
|
||||
"step",
|
||||
"expectedResults",
|
||||
"expectedResult",
|
||||
"expectations",
|
||||
"apiContext",
|
||||
"apis",
|
||||
"apiInfo",
|
||||
"generateOptions",
|
||||
"options",
|
||||
}
|
||||
extra = {key: value for key, value in payload.items() if key not in known_keys}
|
||||
|
||||
return FunctionalCaseInput(
|
||||
caseId=str(payload.get("caseId", "") or ""),
|
||||
caseKey=str(payload.get("caseKey", "") or ""),
|
||||
caseName=str(payload.get("caseName", "") or payload.get("name", "") or ""),
|
||||
projectName=str(payload.get("projectName", "") or ""),
|
||||
moduleName=str(payload.get("moduleName", "") or ""),
|
||||
priority=str(payload.get("priority", "") or ""),
|
||||
preconditions=str(payload.get("preconditions", "") or payload.get("precondition", "") or ""),
|
||||
steps=steps,
|
||||
expectedResults=expected_results,
|
||||
apiContext=api_context,
|
||||
generateOptions=generate_options if isinstance(generate_options, dict) else {},
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_steps(value: Any) -> List[FunctionalStep]:
|
||||
if not value:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
raw_items = [item.strip() for item in re.split(r"[\n\r]+", value) if item.strip()]
|
||||
if len(raw_items) <= 1:
|
||||
raw_items = [item.strip() for item in re.split(r"(?<=[。;;])", value) if item.strip()]
|
||||
items = raw_items
|
||||
elif isinstance(value, list):
|
||||
items = value
|
||||
else:
|
||||
items = [value]
|
||||
|
||||
normalized: List[FunctionalStep] = []
|
||||
for index, item in enumerate(items, start=1):
|
||||
if isinstance(item, dict):
|
||||
step_no = int(item.get("stepNo") or item.get("step_no") or item.get("no") or index)
|
||||
action = str(item.get("action") or item.get("step") or item.get("content") or item.get("description") or "")
|
||||
expected_result = str(item.get("expectedResult") or item.get("expected") or "")
|
||||
else:
|
||||
raw_text = str(item).strip()
|
||||
step_no = index
|
||||
action = re.sub(r"^\s*\d+[\.、\)]\s*", "", raw_text)
|
||||
expected_result = ""
|
||||
if action:
|
||||
normalized.append(FunctionalStep(stepNo=step_no, action=action, expectedResult=expected_result))
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text_list(value: Any) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = [item.strip() for item in re.split(r"[\n\r]+", value) if item.strip()]
|
||||
if len(parts) <= 1:
|
||||
parts = [item.strip() for item in re.split(r"[。;;]\s*", value) if item.strip()]
|
||||
return [item for item in parts if item]
|
||||
if isinstance(value, list):
|
||||
result = []
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
text = str(item.get("text") or item.get("content") or item.get("expected") or "").strip()
|
||||
else:
|
||||
text = str(item).strip()
|
||||
if text:
|
||||
result.append(text)
|
||||
return result
|
||||
return [str(value).strip()] if str(value).strip() else []
|
||||
|
||||
@staticmethod
|
||||
def _normalize_api_context(value: Any) -> Optional[List[ApiContextItem]]:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, dict):
|
||||
if "apis" in value and isinstance(value["apis"], list):
|
||||
value = value["apis"]
|
||||
elif "items" in value and isinstance(value["items"], list):
|
||||
value = value["items"]
|
||||
else:
|
||||
value = [value]
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return FunctionalCaseParser._normalize_api_context(parsed)
|
||||
except Exception:
|
||||
return FunctionalCaseParser._parse_markdown_api_context(value)
|
||||
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
|
||||
apis: List[ApiContextItem] = []
|
||||
for item in value:
|
||||
if isinstance(item, ApiContextItem):
|
||||
apis.append(item)
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
headers = item.get("headers") or item.get("header") or {}
|
||||
query_params = item.get("queryParams") or item.get("query") or item.get("params") or {}
|
||||
tags = item.get("tags") or []
|
||||
if isinstance(tags, str):
|
||||
tags = [tag.strip() for tag in re.split(r"[\s,,/|]+", tags) if tag.strip()]
|
||||
apis.append(
|
||||
ApiContextItem(
|
||||
apiName=str(item.get("apiName") or item.get("name") or ""),
|
||||
method=str(item.get("method") or item.get("httpMethod") or "").upper(),
|
||||
url=str(item.get("url") or item.get("path") or ""),
|
||||
description=str(item.get("description") or item.get("desc") or ""),
|
||||
headers=headers if isinstance(headers, dict) else {},
|
||||
queryParams=query_params if isinstance(query_params, dict) else {},
|
||||
requestBody=item.get("requestBody") if "requestBody" in item else item.get("body"),
|
||||
responseExample=item.get("responseExample") or item.get("response") or item.get("example"),
|
||||
tags=tags if isinstance(tags, list) else [],
|
||||
)
|
||||
)
|
||||
return apis or None
|
||||
|
||||
@staticmethod
|
||||
def _parse_markdown_api_context(text: str) -> Optional[List[ApiContextItem]]:
|
||||
lines = text.splitlines()
|
||||
apis: List[ApiContextItem] = []
|
||||
current: Dict[str, Any] = {}
|
||||
in_body = False
|
||||
body_lines: List[str] = []
|
||||
|
||||
def flush_current():
|
||||
nonlocal current, body_lines, in_body
|
||||
if not current:
|
||||
return
|
||||
body_text = "\n".join(body_lines).strip()
|
||||
request_body = FunctionalCaseParser._safe_json_load(body_text)
|
||||
apis.append(
|
||||
ApiContextItem(
|
||||
apiName=current.get("apiName", ""),
|
||||
method=current.get("method", ""),
|
||||
url=current.get("url", ""),
|
||||
description=current.get("description", ""),
|
||||
headers=current.get("headers", {}),
|
||||
queryParams=current.get("queryParams", {}),
|
||||
requestBody=request_body if request_body is not None else body_text,
|
||||
responseExample=current.get("responseExample"),
|
||||
tags=current.get("tags", []),
|
||||
)
|
||||
)
|
||||
current = {}
|
||||
body_lines = []
|
||||
in_body = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("## "):
|
||||
flush_current()
|
||||
current["apiName"] = stripped[3:].strip()
|
||||
continue
|
||||
if stripped == "**接口URL**":
|
||||
continue
|
||||
if stripped.startswith("> ") and not current.get("url"):
|
||||
value = stripped[2:].strip()
|
||||
if value and value != "暂无参数":
|
||||
current["url"] = value
|
||||
continue
|
||||
if stripped == "**请求方式**":
|
||||
continue
|
||||
if stripped == "**请求Body参数**":
|
||||
in_body = True
|
||||
body_lines = []
|
||||
continue
|
||||
if stripped == "**响应示例**":
|
||||
in_body = False
|
||||
continue
|
||||
if in_body:
|
||||
if stripped.startswith("```"):
|
||||
continue
|
||||
body_lines.append(line)
|
||||
|
||||
flush_current()
|
||||
return apis or None
|
||||
|
||||
@staticmethod
|
||||
def _safe_json_load(text: str) -> Any:
|
||||
if not text:
|
||||
return None
|
||||
cleaned = text.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", cleaned)
|
||||
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class ApiMatcher:
|
||||
def match(self, functional_case: FunctionalCaseInput, candidates: List[ApiContextItem], top_k: int = 3) -> List[Dict[str, Any]]:
|
||||
matched: List[Dict[str, Any]] = []
|
||||
keywords = self.extract_keywords(functional_case)
|
||||
intent = self.detect_intent(functional_case)
|
||||
|
||||
for api in candidates:
|
||||
score = self._score_api(api, keywords, intent, functional_case)
|
||||
if score <= 0:
|
||||
continue
|
||||
matched.append(
|
||||
{
|
||||
"score": round(score, 4),
|
||||
"api": api,
|
||||
"reason": self._build_reason(api, keywords, intent),
|
||||
}
|
||||
)
|
||||
|
||||
matched.sort(key=lambda item: item["score"], reverse=True)
|
||||
return matched[:top_k]
|
||||
|
||||
def extract_keywords(self, functional_case: FunctionalCaseInput) -> List[str]:
|
||||
texts = [functional_case.caseName, functional_case.moduleName, functional_case.preconditions]
|
||||
texts.extend(step.action for step in functional_case.steps)
|
||||
texts.extend(step.expectedResult for step in functional_case.steps if step.expectedResult)
|
||||
texts.extend(functional_case.expectedResults)
|
||||
texts.extend(str(value) for value in functional_case.extra.values() if value is not None)
|
||||
full_text = " \n ".join(texts)
|
||||
keywords: List[str] = []
|
||||
|
||||
for keyword in COMMON_CN_KEYWORDS:
|
||||
if keyword in full_text and keyword not in keywords:
|
||||
keywords.append(keyword)
|
||||
|
||||
english_tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_]{1,}", full_text)
|
||||
for token in english_tokens:
|
||||
if token not in keywords:
|
||||
keywords.append(token)
|
||||
|
||||
generic_tokens = re.findall(r"[\u4e00-\u9fa5]{2,}", full_text)
|
||||
for token in generic_tokens:
|
||||
if len(token) <= 2:
|
||||
if token not in keywords:
|
||||
keywords.append(token)
|
||||
continue
|
||||
if token not in keywords:
|
||||
keywords.append(token)
|
||||
|
||||
return self._dedupe(keywords)
|
||||
|
||||
def detect_intent(self, functional_case: FunctionalCaseInput) -> str:
|
||||
text = self._all_text(functional_case)
|
||||
for method, keywords in METHOD_INTENT_KEYWORDS.items():
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return method
|
||||
return "GET"
|
||||
|
||||
def _score_api(self, api: ApiContextItem, keywords: List[str], intent: str, functional_case: FunctionalCaseInput) -> float:
|
||||
score = 0.0
|
||||
api_text = " ".join(
|
||||
[
|
||||
api.apiName or "",
|
||||
api.method or "",
|
||||
api.url or "",
|
||||
api.description or "",
|
||||
" ".join(api.tags or []),
|
||||
" ".join(api.headers.keys()),
|
||||
" ".join(api.queryParams.keys()),
|
||||
]
|
||||
)
|
||||
if api.method and api.method.upper() == intent:
|
||||
score += 1.2
|
||||
elif api.method and api.method.upper() in ("GET", "POST", "PUT", "PATCH", "DELETE"):
|
||||
score += 0.2
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword and keyword in api_text:
|
||||
score += 0.8
|
||||
|
||||
for token in self._url_tokens(api.url):
|
||||
if token and any(keyword in token or token in keyword for keyword in keywords):
|
||||
score += 0.6
|
||||
|
||||
if functional_case.moduleName and functional_case.moduleName in api_text:
|
||||
score += 0.8
|
||||
if functional_case.caseName and functional_case.caseName in api_text:
|
||||
score += 0.5
|
||||
|
||||
ratio = SequenceMatcher(None, self._all_text(functional_case), api_text).ratio()
|
||||
score += ratio * 0.8
|
||||
return score
|
||||
|
||||
@staticmethod
|
||||
def _build_reason(api: ApiContextItem, keywords: List[str], intent: str) -> str:
|
||||
matched_keywords = [keyword for keyword in keywords if keyword and keyword in (api.apiName + " " + api.description + " " + api.url)]
|
||||
parts = []
|
||||
if api.method and api.method.upper() == intent:
|
||||
parts.append("方法匹配")
|
||||
if matched_keywords:
|
||||
parts.append("关键词命中:{}".format("、".join(matched_keywords[:5])))
|
||||
return "; ".join(parts) or "模糊匹配"
|
||||
|
||||
@staticmethod
|
||||
def _url_tokens(url: str) -> List[str]:
|
||||
if not url:
|
||||
return []
|
||||
return [token for token in re.split(r"[/?&=_\-\.]+", url) if token]
|
||||
|
||||
@staticmethod
|
||||
def _dedupe(items: Iterable[str]) -> List[str]:
|
||||
seen = set()
|
||||
result = []
|
||||
for item in items:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _all_text(functional_case: FunctionalCaseInput) -> str:
|
||||
texts = [functional_case.caseName, functional_case.moduleName, functional_case.preconditions]
|
||||
texts.extend(step.action for step in functional_case.steps)
|
||||
texts.extend(step.expectedResult for step in functional_case.steps if step.expectedResult)
|
||||
texts.extend(functional_case.expectedResults)
|
||||
return " \n ".join(filter(None, texts))
|
||||
|
||||
|
||||
class LLMApiCaseConverter:
|
||||
def convert(self, prompt: str, llm_client: Any = None) -> Optional[Dict[str, Any]]:
|
||||
if llm_client is None:
|
||||
return None
|
||||
|
||||
raw = self._invoke_client(llm_client, prompt)
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if hasattr(raw, "content"):
|
||||
raw = raw.content
|
||||
elif hasattr(raw, "text"):
|
||||
raw = raw.text
|
||||
elif hasattr(raw, "data"):
|
||||
raw = raw.data
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
raw = str(raw)
|
||||
return self._extract_json(raw)
|
||||
|
||||
@staticmethod
|
||||
def _invoke_client(llm_client: Any, prompt: str) -> Any:
|
||||
if callable(llm_client):
|
||||
try:
|
||||
return llm_client(prompt=prompt)
|
||||
except TypeError:
|
||||
try:
|
||||
return llm_client(prompt)
|
||||
except TypeError:
|
||||
return llm_client({"prompt": prompt})
|
||||
if hasattr(llm_client, "generate") and callable(llm_client.generate):
|
||||
return llm_client.generate(prompt)
|
||||
if hasattr(llm_client, "chat") and callable(llm_client.chat):
|
||||
return llm_client.chat(prompt)
|
||||
raise TypeError("llm_client must be callable or expose generate/chat")
|
||||
|
||||
@staticmethod
|
||||
def _extract_json(text: str) -> Optional[Dict[str, Any]]:
|
||||
if not text:
|
||||
return None
|
||||
cleaned = text.strip()
|
||||
match = re.search(r"```json\s*(\{.*?\})\s*```", cleaned, flags=re.S)
|
||||
if match:
|
||||
cleaned = match.group(1)
|
||||
else:
|
||||
first = cleaned.find("{")
|
||||
last = cleaned.rfind("}")
|
||||
if first >= 0 and last > first:
|
||||
cleaned = cleaned[first : last + 1]
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class FunctionalCaseToApiAutomationService:
|
||||
def __init__(self, matcher: Optional[ApiMatcher] = None):
|
||||
self.matcher = matcher or ApiMatcher()
|
||||
self.llm_converter = LLMApiCaseConverter()
|
||||
|
||||
def convert(self, payload: Union[str, Dict[str, Any], FunctionalCaseInput], llm_client: Any = None) -> Dict[str, Any]:
|
||||
functional_case = FunctionalCaseParser.parse(payload)
|
||||
options = self._build_options(functional_case)
|
||||
candidates = functional_case.apiContext or []
|
||||
matched = self.matcher.match(functional_case, candidates, top_k=int(options.get("topK", 3))) if candidates else []
|
||||
|
||||
llm_result = None
|
||||
if options.get("useLLM", True) and llm_client is not None:
|
||||
prompt = self.build_prompt(functional_case, matched, options)
|
||||
llm_result = self.llm_converter.convert(prompt, llm_client)
|
||||
|
||||
if llm_result:
|
||||
return self._finalize_llm_result(functional_case, matched, llm_result, options)
|
||||
|
||||
return self._build_rule_result(functional_case, matched, options)
|
||||
|
||||
def build_prompt(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], options: Dict[str, Any]) -> str:
|
||||
payload = {
|
||||
"functionalCase": asdict(functional_case),
|
||||
"matchedApis": [
|
||||
{
|
||||
"score": item["score"],
|
||||
"reason": item["reason"],
|
||||
"api": asdict(item["api"]),
|
||||
}
|
||||
for item in matched
|
||||
],
|
||||
"options": options,
|
||||
}
|
||||
return (
|
||||
"你是接口自动化测试用例生成专家。\n"
|
||||
"请把功能测试用例转换成接口自动化测试用例。\n"
|
||||
"要求:\n"
|
||||
"1. 只输出严格 JSON。\n"
|
||||
"2. 如果给了候选接口信息,优先使用候选接口。\n"
|
||||
"3. 无法确认的接口信息写入 missingInfo。\n"
|
||||
"4. 需要包含 method、url、headers、queryParams、body、assertions、performanceAssertions。\n"
|
||||
"5. 如果涉及性能要求,生成 responseTime 断言。\n"
|
||||
"6. 如果涉及登录态,使用 ${token}。\n\n"
|
||||
"输入数据:\n"
|
||||
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n\n"
|
||||
"输出 JSON 结构:\n"
|
||||
"{\n"
|
||||
' "caseId": "",\n'
|
||||
' "caseKey": "",\n'
|
||||
' "caseName": "",\n'
|
||||
' "automationType": "api",\n'
|
||||
' "convertStatus": "SUCCESS",\n'
|
||||
' "apiTestCases": [],\n'
|
||||
' "missingInfo": [],\n'
|
||||
' "warnings": []\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
def _build_options(self, functional_case: FunctionalCaseInput) -> Dict[str, Any]:
|
||||
options = copy.deepcopy(functional_case.generateOptions or {})
|
||||
options.setdefault("useLLM", True)
|
||||
options.setdefault("outputFormat", "json")
|
||||
options.setdefault("targetFramework", "pytest")
|
||||
options.setdefault("allowMissingInfo", True)
|
||||
options.setdefault("topK", 3)
|
||||
return options
|
||||
|
||||
def _build_rule_result(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
api_test_cases: List[Dict[str, Any]] = []
|
||||
missing_info: List[str] = []
|
||||
warnings: List[str] = []
|
||||
|
||||
if matched:
|
||||
for index, item in enumerate(matched, start=1):
|
||||
api_test_cases.append(self._build_api_test_case(functional_case, item["api"], index, item["score"], options))
|
||||
else:
|
||||
api_test_cases.append(self._build_fallback_test_case(functional_case, options))
|
||||
missing_info.extend(self._collect_missing_info(functional_case))
|
||||
warnings.append("未匹配到真实接口信息,已生成占位自动化用例")
|
||||
|
||||
missing_info = self._dedupe_text(missing_info)
|
||||
warnings = self._dedupe_text(warnings)
|
||||
|
||||
convert_status = "SUCCESS"
|
||||
if missing_info:
|
||||
convert_status = "SUCCESS_WITH_MISSING_INFO"
|
||||
if warnings and not matched:
|
||||
convert_status = "DRAFT"
|
||||
|
||||
return {
|
||||
"caseId": functional_case.caseId,
|
||||
"caseKey": functional_case.caseKey,
|
||||
"caseName": functional_case.caseName,
|
||||
"projectName": functional_case.projectName,
|
||||
"moduleName": functional_case.moduleName,
|
||||
"automationType": "api",
|
||||
"convertStatus": convert_status,
|
||||
"apiTestCases": api_test_cases,
|
||||
"missingInfo": missing_info,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
def _finalize_llm_result(self, functional_case: FunctionalCaseInput, matched: List[Dict[str, Any]], llm_result: Dict[str, Any], options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
result = copy.deepcopy(llm_result)
|
||||
result.setdefault("caseId", functional_case.caseId)
|
||||
result.setdefault("caseKey", functional_case.caseKey)
|
||||
result.setdefault("caseName", functional_case.caseName)
|
||||
result.setdefault("projectName", functional_case.projectName)
|
||||
result.setdefault("moduleName", functional_case.moduleName)
|
||||
result.setdefault("automationType", "api")
|
||||
result.setdefault("convertStatus", "SUCCESS")
|
||||
result.setdefault("apiTestCases", [])
|
||||
result.setdefault("missingInfo", [])
|
||||
result.setdefault("warnings", [])
|
||||
|
||||
if not result.get("apiTestCases"):
|
||||
rule_result = self._build_rule_result(functional_case, matched, options)
|
||||
result["apiTestCases"] = rule_result["apiTestCases"]
|
||||
if not result.get("missingInfo"):
|
||||
result["missingInfo"] = rule_result["missingInfo"]
|
||||
if not result.get("warnings"):
|
||||
result["warnings"] = rule_result["warnings"]
|
||||
result["convertStatus"] = rule_result["convertStatus"]
|
||||
|
||||
result["missingInfo"] = self._dedupe_text(result.get("missingInfo", []))
|
||||
result["warnings"] = self._dedupe_text(result.get("warnings", []))
|
||||
return result
|
||||
|
||||
def _build_api_test_case(self, functional_case: FunctionalCaseInput, api: ApiContextItem, index: int, score: float, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
method = (api.method or self.matcher.detect_intent(functional_case)).upper()
|
||||
query_params = copy.deepcopy(api.queryParams or {})
|
||||
body = copy.deepcopy(api.requestBody)
|
||||
headers = copy.deepcopy(api.headers or {})
|
||||
if not headers:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if not any(key.lower() == "authorization" for key in headers):
|
||||
headers["Authorization"] = "Bearer ${token}"
|
||||
|
||||
variables = self._build_variables(functional_case, api, query_params, body)
|
||||
query_params = self._fill_placeholders(query_params, variables)
|
||||
body = self._fill_placeholders(body, variables)
|
||||
|
||||
assertions = self._build_assertions(functional_case, api, method)
|
||||
performance_assertions = self._build_performance_assertions(functional_case)
|
||||
step_name = api.apiName or functional_case.caseName or "接口自动化步骤"
|
||||
|
||||
return {
|
||||
"stepNo": index,
|
||||
"name": step_name,
|
||||
"apiName": api.apiName or step_name,
|
||||
"method": method,
|
||||
"url": api.url or self._infer_placeholder_url(functional_case),
|
||||
"headers": headers,
|
||||
"queryParams": query_params,
|
||||
"body": body,
|
||||
"extractVariables": [],
|
||||
"assertions": assertions,
|
||||
"performanceAssertions": performance_assertions,
|
||||
"variables": variables,
|
||||
"matchedApi": {
|
||||
"apiName": api.apiName,
|
||||
"method": method,
|
||||
"url": api.url,
|
||||
"matchScore": round(score, 4),
|
||||
},
|
||||
}
|
||||
|
||||
def _build_fallback_test_case(self, functional_case: FunctionalCaseInput, options: Dict[str, Any]) -> Dict[str, Any]:
|
||||
method = self.matcher.detect_intent(functional_case)
|
||||
url = self._infer_placeholder_url(functional_case)
|
||||
variables = self._build_variables(functional_case, None, {}, None)
|
||||
return {
|
||||
"stepNo": 1,
|
||||
"name": functional_case.caseName or "功能用例转接口自动化",
|
||||
"apiName": functional_case.caseName or "待确认接口",
|
||||
"method": method,
|
||||
"url": url,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer ${token}",
|
||||
},
|
||||
"queryParams": {},
|
||||
"body": None,
|
||||
"extractVariables": [],
|
||||
"assertions": self._build_assertions(functional_case, None, method),
|
||||
"performanceAssertions": self._build_performance_assertions(functional_case),
|
||||
"variables": variables,
|
||||
"matchedApi": {
|
||||
"apiName": "",
|
||||
"method": method,
|
||||
"url": url,
|
||||
"matchScore": 0,
|
||||
},
|
||||
}
|
||||
|
||||
def _build_variables(self, functional_case: FunctionalCaseInput, api: Optional[ApiContextItem], query_params: Dict[str, Any], body: Any) -> Dict[str, Any]:
|
||||
variables: Dict[str, Any] = {}
|
||||
text = self.matcher._all_text(functional_case)
|
||||
for keyword in self.matcher.extract_keywords(functional_case):
|
||||
if keyword in ("搜索", "查询", "列表", "筛选", "关键字", "模糊搜索"):
|
||||
variables.setdefault("keyword", "测试")
|
||||
if keyword in ("分页", "分页查询"):
|
||||
variables.setdefault("page", 1)
|
||||
variables.setdefault("limit", DEFAULT_PAGE_SIZE)
|
||||
if keyword in ("登录",):
|
||||
variables.setdefault("token", "${token}")
|
||||
if not variables and text:
|
||||
variables["keyword"] = "测试"
|
||||
if query_params:
|
||||
for key in query_params.keys():
|
||||
if key not in variables and any(token in key.lower() for token in ["keyword", "name", "id", "page", "limit", "status", "type"]):
|
||||
variables[key] = query_params[key]
|
||||
if isinstance(body, dict):
|
||||
for key in body.keys():
|
||||
if key not in variables and any(token in key.lower() for token in ["keyword", "name", "id", "page", "limit", "status", "type"]):
|
||||
variables[key] = body[key]
|
||||
return variables
|
||||
|
||||
def _build_assertions(self, functional_case: FunctionalCaseInput, api: Optional[ApiContextItem], method: str) -> List[Dict[str, Any]]:
|
||||
assertions = [
|
||||
{
|
||||
"type": "statusCode",
|
||||
"actual": "$.code" if api or method != "DELETE" else "$.status",
|
||||
"operator": "==",
|
||||
"expected": 200,
|
||||
}
|
||||
]
|
||||
text = self.matcher._all_text(functional_case)
|
||||
if api and isinstance(api.responseExample, dict):
|
||||
example = api.responseExample
|
||||
if "code" in example:
|
||||
assertions.append(
|
||||
{
|
||||
"type": "jsonPath",
|
||||
"actual": "$.code",
|
||||
"operator": "==",
|
||||
"expected": example.get("code", 0),
|
||||
}
|
||||
)
|
||||
if "msg" in example:
|
||||
assertions.append(
|
||||
{
|
||||
"type": "jsonPath",
|
||||
"actual": "$.msg",
|
||||
"operator": "notEmpty",
|
||||
"expected": True,
|
||||
}
|
||||
)
|
||||
|
||||
if any(keyword in text for keyword in ["搜索", "查询", "列表", "筛选", "返回"]):
|
||||
assertions.append(
|
||||
{
|
||||
"type": "jsonPath",
|
||||
"actual": "$.data",
|
||||
"operator": "notEmpty",
|
||||
"expected": True,
|
||||
}
|
||||
)
|
||||
return assertions
|
||||
|
||||
def _build_performance_assertions(self, functional_case: FunctionalCaseInput) -> List[Dict[str, Any]]:
|
||||
text = self.matcher._all_text(functional_case)
|
||||
expected_ms = self._extract_response_time_ms(text)
|
||||
if expected_ms is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"type": "responseTime",
|
||||
"operator": "<=",
|
||||
"expected": expected_ms,
|
||||
"unit": "ms",
|
||||
}
|
||||
]
|
||||
|
||||
def _collect_missing_info(self, functional_case: FunctionalCaseInput) -> List[str]:
|
||||
missing = []
|
||||
if not functional_case.steps:
|
||||
missing.append("缺少步骤信息")
|
||||
if not functional_case.caseName:
|
||||
missing.append("缺少用例名称")
|
||||
if not functional_case.apiContext:
|
||||
missing.append("未传入真实接口信息,已使用占位接口生成")
|
||||
if self._extract_response_time_ms(self.matcher._all_text(functional_case)) is None and any(k in self.matcher._all_text(functional_case) for k in ["性能", "响应时间", "超时"]):
|
||||
missing.append("性能阈值未明确,默认使用 1000ms")
|
||||
return missing
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_time_ms(text: str) -> Optional[int]:
|
||||
if not text:
|
||||
return None
|
||||
patterns = [
|
||||
r"(\d+)\s*毫秒",
|
||||
r"(\d+)\s*ms",
|
||||
r"(\d+)\s*秒",
|
||||
r"不超过\s*(\d+)\s*秒",
|
||||
r"<=\s*(\d+)\s*秒",
|
||||
r"小于等于\s*(\d+)\s*秒",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.I)
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
if "秒" in pattern:
|
||||
return value * 1000
|
||||
return value
|
||||
if any(keyword in text for keyword in ["响应时间", "超时", "性能"]):
|
||||
return DEFAULT_RESPONSE_TIME_MS
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _infer_placeholder_url(functional_case: FunctionalCaseInput) -> str:
|
||||
text = " ".join([functional_case.caseName, functional_case.moduleName] + [step.action for step in functional_case.steps])
|
||||
if any(keyword in text for keyword in ["登录"]):
|
||||
return "/api/login"
|
||||
if any(keyword in text for keyword in ["搜索", "查询", "列表", "筛选"]):
|
||||
return "/api/list/query"
|
||||
if any(keyword in text for keyword in ["新增", "创建", "添加"]):
|
||||
return "/api/create"
|
||||
if any(keyword in text for keyword in ["修改", "编辑", "更新"]):
|
||||
return "/api/update"
|
||||
if any(keyword in text for keyword in ["删除", "移除"]):
|
||||
return "/api/delete"
|
||||
return DEFAULT_PLACEHOLDER_URL
|
||||
|
||||
@staticmethod
|
||||
def _fill_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: FunctionalCaseToApiAutomationService._fill_placeholders(item, variables) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [FunctionalCaseToApiAutomationService._fill_placeholders(item, variables) for item in value]
|
||||
if isinstance(value, str):
|
||||
result = value
|
||||
for key, variable in variables.items():
|
||||
result = result.replace("${" + key + "}", str(variable))
|
||||
return result
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_text(items: Iterable[str]) -> List[str]:
|
||||
seen = set()
|
||||
result = []
|
||||
for item in items:
|
||||
text = str(item).strip()
|
||||
if not text or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
result.append(text)
|
||||
return result
|
||||
|
||||
|
||||
def convert_functional_case(payload: Union[str, Dict[str, Any], FunctionalCaseInput], llm_client: Any = None) -> Dict[str, Any]:
|
||||
return FunctionalCaseToApiAutomationService().convert(payload, llm_client=llm_client)
|
||||
|
||||
|
||||
def load_payload_from_file(file_path: str) -> Dict[str, Any]:
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="功能测试用例转接口自动化用例")
|
||||
parser.add_argument("--input", type=str, help="输入JSON文件路径,未指定则从stdin读取")
|
||||
parser.add_argument("--output", type=str, help="输出JSON文件路径,未指定则打印到stdout")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.input:
|
||||
payload = load_payload_from_file(args.input)
|
||||
else:
|
||||
payload = json.load(os.sys.stdin)
|
||||
|
||||
result = convert_functional_case(payload)
|
||||
|
||||
output = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as file:
|
||||
file.write(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
189
joyhub_backend/library/hubops_parser.py
Normal file
189
joyhub_backend/library/hubops_parser.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
DEFAULT_HUBOPS_PATH = os.path.join(PROJECT_ROOT, "HubOps.md")
|
||||
|
||||
|
||||
class HubOpsParser(object):
|
||||
def __init__(self, file_path=None):
|
||||
self.file_path = file_path or os.getenv("JOYHUB_DOC_PATH", DEFAULT_HUBOPS_PATH)
|
||||
|
||||
def parse(self):
|
||||
with open(self.file_path, "r", encoding="utf-8") as file:
|
||||
lines = file.read().splitlines()
|
||||
|
||||
cases = []
|
||||
headings = []
|
||||
for index, line in enumerate(lines):
|
||||
heading = self._parse_heading(line)
|
||||
if heading:
|
||||
level, title = heading
|
||||
headings = [item for item in headings if item[0] < level]
|
||||
headings.append((level, title))
|
||||
continue
|
||||
|
||||
if line.strip() != "**接口URL**":
|
||||
continue
|
||||
|
||||
case = self._parse_case(lines, index, headings)
|
||||
if case.get("url"):
|
||||
case["case_id"] = "joyhub_{:04d}".format(len(cases) + 1)
|
||||
cases.append(case)
|
||||
return cases
|
||||
|
||||
def _parse_case(self, lines, url_index, headings):
|
||||
title = self._case_title(headings, url_index)
|
||||
url = self._next_quote_value(lines, url_index)
|
||||
method = self._find_section_quote(lines, url_index, "**请求方式**") or "POST"
|
||||
content_type = self._find_section_quote(lines, url_index, "**Content-Type**") or "json"
|
||||
body_text = self._find_code_block(lines, url_index, "**请求Body参数**")
|
||||
query = self._find_param_table(lines, url_index, "**请求Query参数**")
|
||||
headers = self._find_headers(lines, url_index)
|
||||
body = self._parse_body(body_text)
|
||||
return {
|
||||
"name": title,
|
||||
"method": method.upper(),
|
||||
"url": url,
|
||||
"content_type": content_type,
|
||||
"headers": headers,
|
||||
"query": query,
|
||||
"body": body,
|
||||
"raw_body": body_text,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_heading(line):
|
||||
match = re.match(r"^(#{2,6})\s+(.+?)\s*$", line)
|
||||
if not match:
|
||||
return None
|
||||
return len(match.group(1)), match.group(2).strip()
|
||||
|
||||
@staticmethod
|
||||
def _case_title(headings, url_index):
|
||||
if headings:
|
||||
return " / ".join(title for _, title in headings[-3:])
|
||||
return "HubOps接口{}".format(url_index + 1)
|
||||
|
||||
@staticmethod
|
||||
def _next_quote_value(lines, start):
|
||||
for index in range(start + 1, min(start + 8, len(lines))):
|
||||
line = lines[index].strip()
|
||||
if line.startswith(">"):
|
||||
value = line[1:].strip()
|
||||
if value and value != "暂无参数":
|
||||
return value
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _section_end(lines, start):
|
||||
for index in range(start + 1, len(lines)):
|
||||
line = lines[index].strip()
|
||||
if line.startswith("## ") or line.startswith("### ") or line == "**接口URL**":
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
def _find_section_quote(self, lines, url_index, section_name):
|
||||
start = max(0, url_index - 80)
|
||||
end = self._section_end(lines, url_index)
|
||||
for index in range(start, min(end, len(lines))):
|
||||
if lines[index].strip() == section_name:
|
||||
return self._next_quote_value(lines, index)
|
||||
return None
|
||||
|
||||
def _find_code_block(self, lines, url_index, section_name):
|
||||
end = self._section_end(lines, url_index)
|
||||
for index in range(url_index, end):
|
||||
if lines[index].strip() != section_name:
|
||||
continue
|
||||
for code_start in range(index + 1, end):
|
||||
if lines[code_start].strip().startswith("```"):
|
||||
block = []
|
||||
for code_end in range(code_start + 1, end):
|
||||
if lines[code_end].strip().startswith("```"):
|
||||
return "\n".join(block).strip()
|
||||
block.append(lines[code_end])
|
||||
return ""
|
||||
|
||||
def _find_param_table(self, lines, url_index, section_name):
|
||||
end = self._section_end(lines, url_index)
|
||||
for index in range(url_index, end):
|
||||
if lines[index].strip() != section_name:
|
||||
continue
|
||||
params = {}
|
||||
for row_index in range(index + 1, end):
|
||||
row = lines[row_index].strip()
|
||||
if not row.startswith("|"):
|
||||
if params:
|
||||
break
|
||||
continue
|
||||
if "---" in row or "参数名" in row or "暂无参数" in row:
|
||||
continue
|
||||
columns = [column.strip() for column in row.strip("|").split("|")]
|
||||
if len(columns) >= 2 and columns[0]:
|
||||
params[columns[0]] = self._coerce_value(columns[1])
|
||||
return params
|
||||
return {}
|
||||
|
||||
def _find_headers(self, lines, url_index):
|
||||
headers = {}
|
||||
end = self._section_end(lines, url_index)
|
||||
for index in range(url_index, end):
|
||||
if lines[index].strip() != "**请求Header参数**":
|
||||
continue
|
||||
table_started = False
|
||||
for row_index in range(index + 1, end):
|
||||
row = lines[row_index].strip()
|
||||
if row.startswith("**") or row.startswith("#") or row.startswith("*"):
|
||||
break
|
||||
if not row.startswith("|"):
|
||||
if table_started:
|
||||
break
|
||||
continue
|
||||
table_started = True
|
||||
if "---" in row or "参数名" in row or "暂无参数" in row:
|
||||
continue
|
||||
columns = [column.strip() for column in row.strip("|").split("|")]
|
||||
if len(columns) >= 2 and columns[0] and columns[0] != "Authorization":
|
||||
headers[columns[0]] = columns[1]
|
||||
return headers
|
||||
return headers
|
||||
|
||||
@classmethod
|
||||
def _parse_body(cls, body_text):
|
||||
if not body_text or body_text == "暂无数据":
|
||||
return {}
|
||||
text = cls._clean_body(body_text)
|
||||
for loader in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
value = loader(text)
|
||||
return value if isinstance(value, (dict, list)) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _clean_body(text):
|
||||
text = re.sub(r"//.*", "", text)
|
||||
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||
text = text.replace("{{token}}", "")
|
||||
text = re.sub(r",\s*([}\]])", r"\1", text)
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def _coerce_value(value):
|
||||
if value in ("-", "暂无参数", ""):
|
||||
return ""
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
if value.lower() in ("true", "false"):
|
||||
return value.lower() == "true"
|
||||
return value
|
||||
|
||||
|
||||
def load_hubops_cases():
|
||||
return HubOpsParser().parse()
|
||||
131
joyhub_backend/library/joyhub_interface.py
Normal file
131
joyhub_backend/library/joyhub_interface.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import allure
|
||||
import requests
|
||||
|
||||
from joyhub_backend.library.auth import JoyhubAuth, TIMEOUT
|
||||
|
||||
|
||||
MANAGER_BASE_URL = os.getenv(
|
||||
"JOYHUB_MANAGER_BASE_URL",
|
||||
"http://test-manager-api.best-envision.com",
|
||||
)
|
||||
|
||||
|
||||
class JoyhubInterface(object):
|
||||
def __init__(self):
|
||||
self.auth = JoyhubAuth()
|
||||
self.session = requests.Session()
|
||||
self.base_url = MANAGER_BASE_URL.rstrip("/") + "/"
|
||||
|
||||
def request(self, case_name, method, path, body=None, query=None, headers=None, expected_code=0):
|
||||
url = path if path.startswith("http") else urljoin(self.base_url, path.lstrip("/"))
|
||||
request_headers = self.auth.auth_headers()
|
||||
if headers:
|
||||
request_headers.update({key: value for key, value in headers.items() if key.lower() != "authorization"})
|
||||
|
||||
with allure.step("操作步骤:{}".format(case_name)):
|
||||
self._attach_request(url, method, request_headers, body, query)
|
||||
logging.info("case: %s", case_name)
|
||||
logging.info("request url: %s", url)
|
||||
logging.info("request method: %s", method)
|
||||
logging.info("request headers: %s", request_headers)
|
||||
logging.info("request body: %s", body)
|
||||
logging.info("request query: %s", query)
|
||||
|
||||
request_kwargs = {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"params": query,
|
||||
"headers": request_headers,
|
||||
"timeout": TIMEOUT,
|
||||
}
|
||||
if method.upper() in ("POST", "PUT", "PATCH"):
|
||||
request_kwargs["json"] = body
|
||||
elif body:
|
||||
request_kwargs["params"] = dict(query or {}, **body) if isinstance(body, dict) else query
|
||||
response = self.session.request(**request_kwargs)
|
||||
self._attach_response(response)
|
||||
self._attach_log(case_name, url, method, request_headers, body, query, response)
|
||||
logging.info("response status: %s", response.status_code)
|
||||
logging.info("response body: %s", response.text)
|
||||
|
||||
is_business_api = self._is_business_api(url)
|
||||
assertion_text = "HTTP状态码为200,且业务code为{}".format(expected_code) if is_business_api else "HTTP状态码为2xx,兼容非JSON/SSE响应"
|
||||
allure.attach(assertion_text, "断言内容", allure.attachment_type.TEXT)
|
||||
with allure.step("断言内容:{}".format(assertion_text)):
|
||||
try:
|
||||
if is_business_api:
|
||||
assert response.status_code == 200, "HTTP状态码期望200,实际{},响应{}".format(
|
||||
response.status_code, response.text
|
||||
)
|
||||
data = self._safe_json(response)
|
||||
assert isinstance(data, dict), "业务接口响应不是JSON对象,响应{}".format(response.text)
|
||||
assert "code" in data, "响应缺少 code 字段,响应{}".format(data)
|
||||
assert data.get("code") == expected_code, "业务code期望{},实际{},msg={},响应{}".format(
|
||||
expected_code, data.get("code"), data.get("msg"), data
|
||||
)
|
||||
else:
|
||||
assert 200 <= response.status_code < 300, "HTTP状态码期望2xx,实际{},响应{}".format(
|
||||
response.status_code, response.text
|
||||
)
|
||||
data = self._non_json_result(response)
|
||||
allure.attach("无", "断言失败原因", allure.attachment_type.TEXT)
|
||||
return data
|
||||
except AssertionError as error:
|
||||
allure.attach(str(error), "断言失败原因", allure.attachment_type.TEXT)
|
||||
raise AssertionError("断言失败原因:{}".format(error))
|
||||
|
||||
@staticmethod
|
||||
def _attach_request(url, method, headers, body, query):
|
||||
allure.attach(str(url), "请求url", allure.attachment_type.TEXT)
|
||||
allure.attach(str(method), "请求方式", allure.attachment_type.TEXT)
|
||||
allure.attach(json.dumps(headers, ensure_ascii=False, indent=2), "请求头", allure.attachment_type.JSON)
|
||||
allure.attach(json.dumps(query or {}, ensure_ascii=False, indent=2), "请求query", allure.attachment_type.JSON)
|
||||
allure.attach(json.dumps(body or {}, ensure_ascii=False, indent=2), "请求体", allure.attachment_type.JSON)
|
||||
|
||||
@staticmethod
|
||||
def _attach_response(response):
|
||||
allure.attach(str(response.status_code), "响应状态码", allure.attachment_type.TEXT)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
attachment_type = allure.attachment_type.JSON if "json" in content_type.lower() else allure.attachment_type.TEXT
|
||||
allure.attach(response.text, "响应体/日志", attachment_type)
|
||||
|
||||
def _is_business_api(self, url):
|
||||
return url.startswith(self.base_url)
|
||||
|
||||
@staticmethod
|
||||
def _safe_json(response):
|
||||
if not response.text.strip():
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _non_json_result(response):
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {
|
||||
"status_code": response.status_code,
|
||||
"content_type": response.headers.get("Content-Type", ""),
|
||||
"text": response.text,
|
||||
}
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _attach_log(case_name, url, method, headers, body, query, response):
|
||||
log_text = "\n".join([
|
||||
"case: {}".format(case_name),
|
||||
"request url: {}".format(url),
|
||||
"request method: {}".format(method),
|
||||
"request headers: {}".format(headers),
|
||||
"request query: {}".format(query or {}),
|
||||
"request body: {}".format(body or {}),
|
||||
"response status: {}".format(response.status_code),
|
||||
"response body: {}".format(response.text),
|
||||
])
|
||||
allure.attach(log_text, "日志", allure.attachment_type.TEXT)
|
||||
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import allure
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from joyhub_backend.library.joyhub_interface import JoyhubInterface
|
||||
except Exception:
|
||||
JoyhubInterface = None
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
|
||||
BASE_URL = "http://test-manager-api.best-envision.com"
|
||||
API_PATH = "admin/video/getVideoLabelList"
|
||||
CASE_NAME = "获取视频标签列表"
|
||||
|
||||
|
||||
def _mask_sensitive(data):
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
sensitive_keys = {"authorization", "token", "access_token", "accessToken", "cookie", "password", "passwd"}
|
||||
|
||||
if isinstance(data, dict):
|
||||
masked = {}
|
||||
for key, value in data.items():
|
||||
if str(key).lower() in sensitive_keys:
|
||||
masked[key] = "***MASKED***"
|
||||
else:
|
||||
masked[key] = _mask_sensitive(value)
|
||||
return masked
|
||||
|
||||
if isinstance(data, list):
|
||||
return [_mask_sensitive(item) for item in data]
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _attach_json(name, data):
|
||||
allure.attach(
|
||||
json.dumps(_mask_sensitive(data), ensure_ascii=False, indent=2),
|
||||
name,
|
||||
allure.attachment_type.JSON
|
||||
)
|
||||
|
||||
|
||||
def _normalize_response(response):
|
||||
if isinstance(response, requests.Response):
|
||||
try:
|
||||
response_json = response.json()
|
||||
except ValueError:
|
||||
response_json = None
|
||||
return response.status_code, response_json, response.text
|
||||
|
||||
if isinstance(response, dict):
|
||||
return 200, response, json.dumps(response, ensure_ascii=False)
|
||||
|
||||
return None, None, str(response)
|
||||
|
||||
|
||||
@allure.feature("接口")
|
||||
class TestHubopsVideoLabelList(object):
|
||||
|
||||
def setup_method(self):
|
||||
logging.info("-----------------------------Test Start-------------------------------")
|
||||
|
||||
def teardown_method(self):
|
||||
logging.info("-----------------------------Test End-------------------------------")
|
||||
|
||||
@allure.story("获取视频标签列表")
|
||||
@allure.title("测试HubOps获取视频标签列表接口")
|
||||
def test_get_video_label_list(self):
|
||||
if JoyhubInterface is None:
|
||||
pytest.skip("joyhub_backend项目优先使用JoyhubInterface鉴权封装,请确认joyhub_backend.library.joyhub_interface.JoyhubInterface可导入")
|
||||
|
||||
with allure.step("1. 准备请求参数"):
|
||||
method = "POST"
|
||||
headers = {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
body = {
|
||||
"page": 1,
|
||||
"limit": 10,
|
||||
"sort": "id",
|
||||
"label_name": "",
|
||||
"category_id": 0,
|
||||
"video_type": 0,
|
||||
"video_num": 0,
|
||||
"created_at": [],
|
||||
"order": "descending"
|
||||
}
|
||||
|
||||
logging.info("请求方法: %s", method)
|
||||
logging.info("请求路径: %s", API_PATH)
|
||||
_attach_json("请求头", headers)
|
||||
_attach_json("请求体", body)
|
||||
|
||||
with allure.step("2. 调用HubOps获取视频标签列表接口"):
|
||||
client = JoyhubInterface()
|
||||
response = client.request(
|
||||
CASE_NAME,
|
||||
method,
|
||||
API_PATH,
|
||||
body=body,
|
||||
headers=headers,
|
||||
expected_code=0
|
||||
)
|
||||
|
||||
status_code, response_json, response_text = _normalize_response(response)
|
||||
allure.attach(str(status_code), "HTTP状态码", allure.attachment_type.TEXT)
|
||||
|
||||
if response_json is not None:
|
||||
_attach_json("响应JSON", response_json)
|
||||
else:
|
||||
allure.attach(response_text, "响应内容", allure.attachment_type.TEXT)
|
||||
|
||||
with allure.step("3. 校验响应基础字段"):
|
||||
assert status_code == 200, "HTTP状态码应为200,实际为: {}".format(status_code)
|
||||
assert response_json is not None, "响应内容应为JSON且不能为空"
|
||||
assert isinstance(response_json, dict), "响应JSON应为对象类型"
|
||||
|
||||
assert "code" in response_json, "响应JSON应包含code字段"
|
||||
assert response_json.get("code") == 0, "响应code应为0,实际为: {}".format(response_json.get("code"))
|
||||
|
||||
assert "msg" in response_json, "响应JSON应包含msg字段"
|
||||
assert response_json.get("msg") is not None, "响应msg字段不应为None"
|
||||
|
||||
assert "data" in response_json, "响应JSON应包含data字段"
|
||||
assert response_json.get("data") is not None, "响应data字段不应为None"
|
||||
assert isinstance(response_json.get("data"), list), "响应data字段应为数组类型"
|
||||
|
||||
assert "count" in response_json, "响应JSON应包含count字段"
|
||||
assert response_json.get("count") is not None, "响应count字段不应为None"
|
||||
57
joyhub_backend/test_case/TestCase/接口/test_hub_ops.py
Normal file
57
joyhub_backend/test_case/TestCase/接口/test_hub_ops.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import allure
|
||||
import pytest
|
||||
|
||||
from joyhub_backend.library.hubops_parser import load_hubops_cases
|
||||
from joyhub_backend.library.joyhub_interface import JoyhubInterface
|
||||
|
||||
|
||||
ALL_CASES = load_hubops_cases()
|
||||
|
||||
|
||||
def case_id(case):
|
||||
raw_id = "{}-{}".format(case.get("case_id"), case.get("name"))
|
||||
safe_id = re.sub(r"[^0-9A-Za-z_\u4e00-\u9fa5-]+", "_", raw_id)
|
||||
return safe_id[:120]
|
||||
|
||||
|
||||
@allure.feature("JoyHub Backend 接口自动化")
|
||||
class TestHubOps(object):
|
||||
test_case = JoyhubInterface()
|
||||
|
||||
def teardown_method(self):
|
||||
with allure.step("后置:记录用例结束日志"):
|
||||
logging.info("-----------------------------End-------------------------------")
|
||||
|
||||
@pytest.mark.parametrize("case", ALL_CASES, ids=case_id)
|
||||
def test_hub_ops_api(self, case):
|
||||
allure.dynamic.title("{} {}".format(case.get("case_id"), case.get("name")))
|
||||
allure.dynamic.story(case.get("name"))
|
||||
|
||||
with allure.step("前置:初始化接口客户端并准备鉴权 token"):
|
||||
headers = case.get("headers") or {}
|
||||
query = case.get("query") or {}
|
||||
body = case.get("body") or {}
|
||||
|
||||
response_data = self.test_case.request(
|
||||
case.get("name"),
|
||||
case.get("method"),
|
||||
case.get("url"),
|
||||
body=body,
|
||||
query=query,
|
||||
headers=headers,
|
||||
)
|
||||
with allure.step("断言内容:校验响应基础字段"):
|
||||
request_url = case.get("url") or ""
|
||||
full_url = request_url if request_url.startswith("http") else urljoin(self.test_case.base_url, request_url.lstrip("/"))
|
||||
if full_url.startswith(self.test_case.base_url):
|
||||
assert isinstance(response_data, dict), "断言失败原因:响应不是JSON对象,响应{}".format(response_data)
|
||||
assert "code" in response_data, "断言失败原因:响应缺少code字段,响应{}".format(response_data)
|
||||
assert response_data.get("msg") is not None, "断言失败原因:msg字段不能为空,响应{}".format(response_data)
|
||||
else:
|
||||
assert response_data is not None, "断言失败原因:非业务接口响应为空"
|
||||
logging.info("断言通过:%s", case.get("name"))
|
||||
0
joyhub_backend/test_case/__init__.py
Normal file
0
joyhub_backend/test_case/__init__.py
Normal file
170
joyhub_backend/test_case/run_tests.py
Normal file
170
joyhub_backend/test_case/run_tests.py
Normal file
@@ -0,0 +1,170 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
current_file_path = os.path.abspath(__file__)
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(current_file_path), '../../'))
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
TEST_CASE_DIR = 'joyhub_backend/test_case/TestCase'
|
||||
case_dir = os.path.join(project_root, TEST_CASE_DIR)
|
||||
ALLURE_RESULTS_DIR = os.path.join(project_root, 'joyhub_backend', 'test_case', 'reports', 'allure-results')
|
||||
ALLURE_REPORT_DIR = os.path.join(project_root, 'joyhub_backend', 'test_case', 'reports', 'allure-report')
|
||||
|
||||
|
||||
def ensure_dirs():
|
||||
os.makedirs(ALLURE_RESULTS_DIR, exist_ok=True)
|
||||
os.makedirs(ALLURE_REPORT_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def clean_allure_results():
|
||||
if os.path.exists(ALLURE_RESULTS_DIR):
|
||||
shutil.rmtree(ALLURE_RESULTS_DIR)
|
||||
os.makedirs(ALLURE_RESULTS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def find_test_files(directory):
|
||||
test_files = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith('.py') and not file.startswith('__') and file != 'conftest.py':
|
||||
test_files.append(os.path.join(root, file))
|
||||
return test_files
|
||||
|
||||
|
||||
def run_pytest(args_list):
|
||||
env = os.environ.copy()
|
||||
env['PYTHONPATH'] = project_root + (os.pathsep + env['PYTHONPATH'] if 'PYTHONPATH' in env else '')
|
||||
cmd = ['python', '-m', 'pytest'] + args_list
|
||||
print("开始执行pytest...")
|
||||
print("执行命令: {}".format(' '.join('"{}"'.format(item) if ' ' in item else item for item in cmd)), flush=True)
|
||||
result = subprocess.run(cmd, cwd=project_root, env=env)
|
||||
print("pytest执行结束,退出码: {}".format(result.returncode), flush=True)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_tests(target=None, test_type='all'):
|
||||
base_args = ['-v', '--tb=short', '--alluredir={}'.format(ALLURE_RESULTS_DIR)]
|
||||
if test_type == 'all':
|
||||
print("运行所有测试用例...")
|
||||
test_files = find_test_files(case_dir)
|
||||
if not test_files:
|
||||
print("错误: 未找到测试文件")
|
||||
return 1
|
||||
args = test_files + base_args
|
||||
elif test_type == 'dir':
|
||||
full_path = os.path.join(case_dir, target.replace('/', os.sep).replace('\\', os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
print("错误: 目录不存在: {}".format(full_path))
|
||||
return 1
|
||||
print("按目录运行: {}".format(target))
|
||||
test_files = find_test_files(full_path)
|
||||
if not test_files:
|
||||
print("错误: 未找到测试文件")
|
||||
return 1
|
||||
args = test_files + base_args
|
||||
elif test_type == 'file':
|
||||
full_path = os.path.join(case_dir, target.replace('/', os.sep).replace('\\', os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
print("错误: 文件不存在: {}".format(full_path))
|
||||
return 1
|
||||
print("按文件运行: {}".format(target))
|
||||
args = [full_path] + base_args
|
||||
elif test_type == 'keyword':
|
||||
print("按关键字运行: {}".format(target))
|
||||
test_files = find_test_files(case_dir)
|
||||
args = test_files + ['-k={}'.format(target)] + base_args
|
||||
elif test_type == 'marker':
|
||||
print("按pytest标记运行: {}".format(target))
|
||||
test_files = find_test_files(case_dir)
|
||||
args = test_files + ['-m={}'.format(target)] + base_args
|
||||
elif test_type == 'feature':
|
||||
print("按Allure feature运行: {}".format(target))
|
||||
test_files = find_test_files(case_dir)
|
||||
args = test_files + ['--allure-features={}'.format(target)] + base_args
|
||||
elif test_type == 'story':
|
||||
print("按Allure story运行: {}".format(target))
|
||||
test_files = find_test_files(case_dir)
|
||||
args = test_files + ['--allure-stories={}'.format(target)] + base_args
|
||||
else:
|
||||
print("错误: 未知的测试类型: {}".format(test_type))
|
||||
return 1
|
||||
return run_pytest(args)
|
||||
|
||||
|
||||
def generate_allure_report():
|
||||
print("开始生成Allure报告...", flush=True)
|
||||
if os.path.exists(ALLURE_REPORT_DIR):
|
||||
shutil.rmtree(ALLURE_REPORT_DIR)
|
||||
cmd = 'allure generate "{}" --output "{}"'.format(ALLURE_RESULTS_DIR, ALLURE_REPORT_DIR)
|
||||
print("执行命令: {}".format(cmd), flush=True)
|
||||
try:
|
||||
subprocess.run(cmd, check=True, shell=True)
|
||||
print("Allure报告生成成功: {}".format(ALLURE_REPORT_DIR))
|
||||
print("打开报告命令: allure open \"{}\"".format(ALLURE_REPORT_DIR))
|
||||
return 0
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError) as error:
|
||||
print("生成Allure报告失败: {}".format(error))
|
||||
print("手动执行: {}".format(cmd))
|
||||
return 1
|
||||
|
||||
|
||||
def open_allure_report():
|
||||
cmd = 'allure open "{}"'.format(ALLURE_REPORT_DIR)
|
||||
try:
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
print("Allure报告已打开: {}".format(ALLURE_REPORT_DIR))
|
||||
return 0
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
print("打开Allure报告失败: {}".format(error))
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='JoyHub Backend 接口自动化测试执行工具')
|
||||
run_group = parser.add_mutually_exclusive_group(required=False)
|
||||
run_group.add_argument('--feature', type=str, help='按Allure feature运行')
|
||||
run_group.add_argument('--story', type=str, help='按Allure story运行')
|
||||
run_group.add_argument('--dir', type=str, help='按目录运行(相对于TestCase目录)')
|
||||
run_group.add_argument('--file', type=str, help='按文件运行(相对于TestCase目录)')
|
||||
run_group.add_argument('--keyword', type=str, help='按关键字运行')
|
||||
run_group.add_argument('--marker', type=str, help='按pytest标记运行')
|
||||
parser.add_argument('--report', action='store_true', help='生成Allure报告')
|
||||
parser.add_argument('--open', action='store_true', help='打开Allure报告')
|
||||
parser.add_argument('--no-report', action='store_true', help='不生成Allure报告')
|
||||
args = parser.parse_args()
|
||||
|
||||
ensure_dirs()
|
||||
clean_allure_results()
|
||||
if args.feature:
|
||||
exit_code = run_tests(args.feature, 'feature')
|
||||
elif args.story:
|
||||
exit_code = run_tests(args.story, 'story')
|
||||
elif args.dir:
|
||||
exit_code = run_tests(args.dir, 'dir')
|
||||
elif args.file:
|
||||
exit_code = run_tests(args.file, 'file')
|
||||
elif args.keyword:
|
||||
exit_code = run_tests(args.keyword, 'keyword')
|
||||
elif args.marker:
|
||||
exit_code = run_tests(args.marker, 'marker')
|
||||
else:
|
||||
exit_code = run_tests()
|
||||
|
||||
if args.report or not args.no_report:
|
||||
generate_allure_report()
|
||||
if args.open:
|
||||
open_allure_report()
|
||||
|
||||
print("=" * 80)
|
||||
print("测试执行完成" if exit_code == 0 else "测试执行失败,退出码: {}".format(exit_code))
|
||||
print("=" * 80)
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user