Compare commits
1 Commits
master
...
joyhub_bac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b8382a31 |
@@ -1 +0,0 @@
|
|||||||
# -*- coding:utf-8 -*-
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 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. 验证响应"):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
# -*- 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
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# -*- 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)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# -*- coding:utf-8 -*-
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
# Chat Completions API
|
|
||||||
|
|
||||||
**Source**: https://docs.routin.ai/zh/docs/API/chat-completions
|
|
||||||
**Description**: Chat Completions API 使用指南和多语言示例
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
[Routin AI](https://docs.routin.ai/zh/docs/API/</>)
|
|
||||||
|
|
||||||
[Routin AI](https://docs.routin.ai/zh/docs/API/</>)
|
|
||||||
|
|
||||||
搜索
|
|
||||||
|
|
||||||
`⌘``K`
|
|
||||||
|
|
||||||
🚀 欢迎使用 Routin AI 文档!
|
|
||||||
|
|
||||||
[前往控制台](https://docs.routin.ai/zh/docs/API/<https:/routin.ai/dashboard>)[欢迎使用 Routin AI](https://docs.routin.ai/zh/docs/API/</zh/docs>)
|
|
||||||
|
|
||||||
API 文档
|
|
||||||
|
|
||||||
[Chat Completions API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/chat-completions>)[Embeddings API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/embeddings>)[Images API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/images>)[Audio API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/audio>)[Messages API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/messages>)[Gemini API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/gemini>)[Videos API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/video>)[Web Research API](https://docs.routin.ai/zh/docs/API/</zh/docs/API/web>)
|
|
||||||
|
|
||||||
设计方案
|
|
||||||
|
|
||||||
[套餐订阅制设计](https://docs.routin.ai/zh/docs/API/</zh/docs/Design/plan-subscription>)
|
|
||||||
|
|
||||||
开发者工具
|
|
||||||
|
|
||||||
[接入 Claude Code 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-claude-code>)[接入 Codex 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-codex>)[接入 Kilo Code 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-kilo-code>)[接入 Cherry Studio 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-cherry-studio>)[接入 Gemini CLI 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-gemini>)[Claude Code 完整使用教程](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-claude-code-advanced>)[接入 OpenCode 使用](https://docs.routin.ai/zh/docs/API/</zh/docs/DeveloperTools/access-open-code>)
|
|
||||||
|
|
||||||
Organization
|
|
||||||
|
|
||||||
© 2026 Routin AI
|
|
||||||
|
|
||||||
Chat Completions API
|
|
||||||
|
|
||||||
API 文档
|
|
||||||
|
|
||||||
# Chat Completions API
|
|
||||||
|
|
||||||
Chat Completions API 使用指南和多语言示例
|
|
||||||
|
|
||||||
# Chat Completions API
|
|
||||||
|
|
||||||
MeteorAI 提供完全兼容 OpenAI 的对话接口,您可以使用 OpenAI SDK 直接调用我们的服务。
|
|
||||||
|
|
||||||
## 基本信息
|
|
||||||
|
|
||||||
**API 端点**
|
|
||||||
[code]
|
|
||||||
https://api.routin.ai/v1/chat/completions
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
**认证方式** 在请求头中添加 API Key:
|
|
||||||
[code]
|
|
||||||
Authorization: Bearer YOUR_API_KEY
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
MeteorAI 完全兼容 OpenAI SDK,只需修改 `base_url` 参数即可无缝切换。
|
|
||||||
|
|
||||||
## 请求参数
|
|
||||||
|
|
||||||
### 必需参数
|
|
||||||
|
|
||||||
参数| 类型| 说明
|
|
||||||
---|---|---
|
|
||||||
`model`| string| 模型名称,如 `gpt-4o`、`claude-3-5-sonnet-20241022` 等
|
|
||||||
`messages`| array| 对话消息数组
|
|
||||||
|
|
||||||
### 可选参数
|
|
||||||
|
|
||||||
参数| 类型| 默认值| 说明
|
|
||||||
---|---|---|---
|
|
||||||
`temperature`| number| 1| 采样温度 (0-2)
|
|
||||||
`top_p`| number| 1| 核采样参数 (0-1)
|
|
||||||
`max_tokens`| integer| -| 生成的最大 token 数
|
|
||||||
`stream`| boolean| false| 是否使用流式输出
|
|
||||||
`presence_penalty`| number| 0| 存在惩罚 (-2.0 到 2.0)
|
|
||||||
`frequency_penalty`| number| 0| 频率惩罚 (-2.0 到 2.0)
|
|
||||||
`user`| string| -| 用户标识符
|
|
||||||
|
|
||||||
### Messages 格式
|
|
||||||
[code]
|
|
||||||
{
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "Hello!"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
支持的 `role` 值:
|
|
||||||
|
|
||||||
* `system`: 系统消息,定义助手行为
|
|
||||||
* `user`: 用户消息
|
|
||||||
* `assistant`: 助手回复
|
|
||||||
|
|
||||||
## 响应格式
|
|
||||||
|
|
||||||
### 普通响应
|
|
||||||
[code]
|
|
||||||
{
|
|
||||||
"id": "chatcmpl-123",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 1677652288,
|
|
||||||
"model": "gpt-4o",
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "Hello! How can I help you today?"
|
|
||||||
},
|
|
||||||
"finish_reason": "stop"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 9,
|
|
||||||
"completion_tokens": 12,
|
|
||||||
"total_tokens": 21
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
### 流式响应
|
|
||||||
[code]
|
|
||||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}
|
|
||||||
|
|
||||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}
|
|
||||||
|
|
||||||
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
|
||||||
|
|
||||||
data: [DONE]
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
## 代码示例
|
|
||||||
|
|
||||||
### 基本调用
|
|
||||||
|
|
||||||
PythonTypeScriptJavaScriptC#cURL
|
|
||||||
[code]
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key="YOUR_API_KEY",
|
|
||||||
base_url="https://api.routin.ai/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model="gpt-4o",
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": "You are a helpful assistant."},
|
|
||||||
{"role": "user", "content": "你好,介绍一下你自己"}
|
|
||||||
],
|
|
||||||
temperature=0.7,
|
|
||||||
max_tokens=150
|
|
||||||
)
|
|
||||||
|
|
||||||
print(response.choices[0].message.content)
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
||||||
{ role: 'user', content: '你好,介绍一下你自己' },
|
|
||||||
],
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 150,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(response.choices[0].message.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
const OpenAI = require('openai');
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
||||||
{ role: 'user', content: '你好,介绍一下你自己' },
|
|
||||||
],
|
|
||||||
temperature: 0.7,
|
|
||||||
max_tokens: 150,
|
|
||||||
}).then(response => {
|
|
||||||
console.log(response.choices[0].message.content);
|
|
||||||
});
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
using OpenAI.Chat;
|
|
||||||
|
|
||||||
var client = new ChatClient(
|
|
||||||
model: "gpt-4o",
|
|
||||||
apiKey: "YOUR_API_KEY",
|
|
||||||
new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://api.routin.ai/v1")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new SystemChatMessage("You are a helpful assistant."),
|
|
||||||
new UserChatMessage("你好,介绍一下你自己")
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await client.CompleteChatAsync(
|
|
||||||
messages,
|
|
||||||
new ChatCompletionOptions
|
|
||||||
{
|
|
||||||
Temperature = 0.7f,
|
|
||||||
MaxOutputTokenCount = 150
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Console.WriteLine(response.Value.Content[0].Text);
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
curl https://api.routin.ai/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-4o",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful assistant."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "你好,介绍一下你自己"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"temperature": 0.7,
|
|
||||||
"max_tokens": 150
|
|
||||||
}'
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
### 流式输出
|
|
||||||
|
|
||||||
流式输出可以实时获取模型的生成内容,提供更好的用户体验。
|
|
||||||
|
|
||||||
PythonTypeScriptJavaScriptC#cURL
|
|
||||||
[code]
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key="YOUR_API_KEY",
|
|
||||||
base_url="https://api.routin.ai/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
stream = client.chat.completions.create(
|
|
||||||
model="gpt-4o",
|
|
||||||
messages=[
|
|
||||||
{"role": "user", "content": "给我讲一个有趣的故事"}
|
|
||||||
],
|
|
||||||
stream=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for chunk in stream:
|
|
||||||
if chunk.choices[0].delta.content is not None:
|
|
||||||
print(chunk.choices[0].delta.content, end="")
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const stream = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages: [{ role: 'user', content: '给我讲一个有趣的故事' }],
|
|
||||||
stream: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const content = chunk.choices[0]?.delta?.content;
|
|
||||||
if (content) {
|
|
||||||
process.stdout.write(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
const OpenAI = require('openai');
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const stream = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages: [{ role: 'user', content: '给我讲一个有趣的故事' }],
|
|
||||||
stream: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const content = chunk.choices[0]?.delta?.content;
|
|
||||||
if (content) {
|
|
||||||
process.stdout.write(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
using OpenAI.Chat;
|
|
||||||
|
|
||||||
var client = new ChatClient(
|
|
||||||
model: "gpt-4o",
|
|
||||||
apiKey: "YOUR_API_KEY",
|
|
||||||
new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://api.routin.ai/v1")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new UserChatMessage("给我讲一个有趣的故事")
|
|
||||||
};
|
|
||||||
|
|
||||||
await foreach (var update in client.CompleteChatStreamingAsync(messages))
|
|
||||||
{
|
|
||||||
foreach (var contentPart in update.ContentUpdate)
|
|
||||||
{
|
|
||||||
Console.Write(contentPart.Text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
curl https://api.routin.ai/v1/chat/completions \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-4o",
|
|
||||||
"messages": [
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "给我讲一个有趣的故事"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stream": true
|
|
||||||
}'
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
### 多轮对话
|
|
||||||
|
|
||||||
PythonTypeScriptC#
|
|
||||||
[code]
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key="YOUR_API_KEY",
|
|
||||||
base_url="https://api.routin.ai/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": "You are a helpful assistant."}
|
|
||||||
]
|
|
||||||
|
|
||||||
# 第一轮对话
|
|
||||||
messages.append({"role": "user", "content": "我叫张三"})
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model="gpt-4o",
|
|
||||||
messages=messages
|
|
||||||
)
|
|
||||||
assistant_message = response.choices[0].message.content
|
|
||||||
messages.append({"role": "assistant", "content": assistant_message})
|
|
||||||
print(f"助手: {assistant_message}")
|
|
||||||
|
|
||||||
# 第二轮对话
|
|
||||||
messages.append({"role": "user", "content": "我叫什么名字?"})
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model="gpt-4o",
|
|
||||||
messages=messages
|
|
||||||
)
|
|
||||||
assistant_message = response.choices[0].message.content
|
|
||||||
print(f"助手: {assistant_message}")
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
|
||||||
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// 第一轮对话
|
|
||||||
messages.push({ role: 'user', content: '我叫张三' });
|
|
||||||
let response = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
let assistantMessage = response.choices[0].message.content;
|
|
||||||
messages.push({ role: 'assistant', content: assistantMessage! });
|
|
||||||
console.log(`助手: ${assistantMessage}`);
|
|
||||||
|
|
||||||
// 第二轮对话
|
|
||||||
messages.push({ role: 'user', content: '我叫什么名字?' });
|
|
||||||
response = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
assistantMessage = response.choices[0].message.content;
|
|
||||||
console.log(`助手: ${assistantMessage}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
using OpenAI.Chat;
|
|
||||||
|
|
||||||
var client = new ChatClient(
|
|
||||||
model: "gpt-4o",
|
|
||||||
apiKey: "YOUR_API_KEY",
|
|
||||||
new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://api.routin.ai/v1")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new SystemChatMessage("You are a helpful assistant.")
|
|
||||||
};
|
|
||||||
|
|
||||||
// 第一轮对话
|
|
||||||
messages.Add(new UserChatMessage("我叫张三"));
|
|
||||||
var response = await client.CompleteChatAsync(messages);
|
|
||||||
var assistantMessage = response.Value.Content[0].Text;
|
|
||||||
messages.Add(new AssistantChatMessage(assistantMessage));
|
|
||||||
Console.WriteLine($"助手: {assistantMessage}");
|
|
||||||
|
|
||||||
// 第二轮对话
|
|
||||||
messages.Add(new UserChatMessage("我叫什么名字?"));
|
|
||||||
response = await client.CompleteChatAsync(messages);
|
|
||||||
assistantMessage = response.Value.Content[0].Text;
|
|
||||||
Console.WriteLine($"助手: {assistantMessage}");
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
请务必在生产环境中添加错误处理逻辑,避免因 API 调用失败导致应用崩溃。
|
|
||||||
|
|
||||||
PythonTypeScriptC#
|
|
||||||
[code]
|
|
||||||
from openai import OpenAI, APIError, RateLimitError, APIConnectionError
|
|
||||||
|
|
||||||
client = OpenAI(
|
|
||||||
api_key="YOUR_API_KEY",
|
|
||||||
base_url="https://api.routin.ai/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = client.chat.completions.create(
|
|
||||||
model="gpt-4o",
|
|
||||||
messages=[{"role": "user", "content": "Hello!"}]
|
|
||||||
)
|
|
||||||
print(response.choices[0].message.content)
|
|
||||||
except RateLimitError as e:
|
|
||||||
print(f"请求频率超限: {e}")
|
|
||||||
except APIConnectionError as e:
|
|
||||||
print(f"网络连接错误: {e}")
|
|
||||||
except APIError as e:
|
|
||||||
print(f"API 错误: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"未知错误: {e}")
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
import OpenAI from 'openai';
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
|
||||||
apiKey: 'YOUR_API_KEY',
|
|
||||||
baseURL: 'https://api.routin.ai/v1',
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
const response = await client.chat.completions.create({
|
|
||||||
model: 'gpt-4o',
|
|
||||||
messages: [{ role: 'user', content: 'Hello!' }],
|
|
||||||
});
|
|
||||||
console.log(response.choices[0].message.content);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof OpenAI.APIError) {
|
|
||||||
console.error(`API 错误 [${error.status}]: ${error.message}`);
|
|
||||||
} else if (error instanceof OpenAI.RateLimitError) {
|
|
||||||
console.error('请求频率超限');
|
|
||||||
} else {
|
|
||||||
console.error('未知错误:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
[/code]
|
|
||||||
[code]
|
|
||||||
using OpenAI.Chat;
|
|
||||||
using OpenAI;
|
|
||||||
|
|
||||||
var client = new ChatClient(
|
|
||||||
model: "gpt-4o",
|
|
||||||
apiKey: "YOUR_API_KEY",
|
|
||||||
new OpenAIClientOptions
|
|
||||||
{
|
|
||||||
Endpoint = new Uri("https://api.routin.ai/v1")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await client.CompleteChatAsync(
|
|
||||||
new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new UserChatMessage("Hello!")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Console.WriteLine(response.Value.Content[0].Text);
|
|
||||||
}
|
|
||||||
catch (ClientResultException ex) when (ex.Status == 429)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"请求频率超限: {ex.Message}");
|
|
||||||
}
|
|
||||||
catch (ClientResultException ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"API 错误 [{ex.Status}]: {ex.Message}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"未知错误: {ex.Message}");
|
|
||||||
}
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
## 常见错误码
|
|
||||||
|
|
||||||
错误码| 说明| 解决方法
|
|
||||||
---|---|---
|
|
||||||
401| API Key 无效或未提供| 检查 Authorization 头是否正确设置
|
|
||||||
429| 请求频率超限| 降低请求频率或升级配额
|
|
||||||
400| 请求参数错误| 检查请求参数格式是否正确
|
|
||||||
500| 服务器内部错误| 稍后重试或联系技术支持
|
|
||||||
503| 服务暂时不可用| 稍后重试
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
1. **使用系统消息** : 通过 `system` 角色定义助手的行为和特性
|
|
||||||
2. **控制 token 数量** : 使用 `max_tokens` 参数控制生成长度,避免不必要的费用
|
|
||||||
3. **错误重试** : 实现指数退避的重试机制,处理临时性错误
|
|
||||||
4. **流式输出** : 对于长文本生成,使用 `stream=true` 提供更好的用户体验
|
|
||||||
5. **保存对话历史** : 多轮对话需要在 `messages` 数组中包含完整的对话历史
|
|
||||||
6. **监控使用情况** : 定期查看管理后台的统计信息,优化 API 使用
|
|
||||||
|
|
||||||
## 更多资源
|
|
||||||
|
|
||||||
* [Embeddings API](https://docs.routin.ai/zh/docs/API/</zh/API/embeddings>) \- 文本向量化接口
|
|
||||||
* [Images API](https://docs.routin.ai/zh/docs/API/</zh/API/images>) \- 图像生成接口
|
|
||||||
* [Audio API](https://docs.routin.ai/zh/docs/API/</zh/API/audio>) \- 语音识别和合成接口
|
|
||||||
|
|
||||||
[欢迎使用 Routin AI统一的大模型 API 聚合平台,提供企业级服务和管理能力](https://docs.routin.ai/zh/docs/API/</zh/docs>)[Embeddings API文本向量化 API 使用指南和多语言示例](https://docs.routin.ai/zh/docs/API/</zh/docs/API/embeddings>)
|
|
||||||
|
|
||||||
### On this page
|
|
||||||
|
|
||||||
Chat Completions API基本信息请求参数必需参数可选参数Messages 格式响应格式普通响应流式响应代码示例基本调用流式输出多轮对话错误处理常见错误码最佳实践更多资源
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# -*- coding:utf-8 -*-
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from base_framework.platform_tools.Create_ui_testcase.generate_automation_api import (
|
|
||||||
generate_save_and_verify_ui_testcase
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
API_PATH = "/it/api/case/generate-automation"
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateAutomationHandler(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)
|
|
||||||
|
|
||||||
if request_data.get("automationType") != "ui":
|
|
||||||
self._send_json_response(200, {
|
|
||||||
"code": 1,
|
|
||||||
"message": "automationType不是ui,不调用UI自动化用例生成接口",
|
|
||||||
"data": {
|
|
||||||
"projectId": request_data.get("projectId"),
|
|
||||||
"caseId": request_data.get("caseId"),
|
|
||||||
"automationType": request_data.get("automationType"),
|
|
||||||
"caseKey": request_data.get("caseKey"),
|
|
||||||
"content": self._get_case_name(request_data)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
generation_mode = self._get_generation_mode(request_data)
|
|
||||||
normalized_prompt = self._build_effective_prompt(request_data)
|
|
||||||
normalized_steps = self._normalize_text_list(request_data.get("steps"))
|
|
||||||
normalized_expected_results = self._normalize_text_list(request_data.get("expectedResults"))
|
|
||||||
max_attempts = self._get_max_attempts(request_data, generation_mode)
|
|
||||||
|
|
||||||
verify_result = generate_save_and_verify_ui_testcase(
|
|
||||||
project_id=request_data.get("projectId"),
|
|
||||||
case_id=request_data.get("caseId"),
|
|
||||||
automation_type=request_data.get("automationType"),
|
|
||||||
prompt=normalized_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=normalized_steps,
|
|
||||||
expected_results=normalized_expected_results,
|
|
||||||
case_name=self._get_case_name(request_data),
|
|
||||||
enable_reconnaissance=self._should_enable_reconnaissance(request_data, generation_mode),
|
|
||||||
headless=request_data.get("headless", True),
|
|
||||||
max_attempts=max_attempts
|
|
||||||
)
|
|
||||||
|
|
||||||
response_data = self._build_response_data(request_data, verify_result, generation_mode)
|
|
||||||
if not verify_result.get("success"):
|
|
||||||
self._send_json_response(200, {
|
|
||||||
"code": self._build_failure_code(verify_result),
|
|
||||||
"message": verify_result.get("verificationMessage") or "用例生成后执行失败,自动修复{0}次仍未通过".format(max_attempts),
|
|
||||||
"data": response_data
|
|
||||||
})
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json_response(200, {
|
|
||||||
"code": 0,
|
|
||||||
"message": verify_result.get("verificationMessage") or "success",
|
|
||||||
"data": response_data
|
|
||||||
})
|
|
||||||
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": self._sanitize_error_message(str(error)),
|
|
||||||
"data": None
|
|
||||||
})
|
|
||||||
|
|
||||||
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 _normalize_text_list(self, value):
|
|
||||||
if isinstance(value, list):
|
|
||||||
return "\n".join(str(item) for item in value)
|
|
||||||
return str(value or "")
|
|
||||||
|
|
||||||
def _get_generation_mode(self, request_data):
|
|
||||||
generation_mode = str(request_data.get("generationMode") or "fast").strip().lower()
|
|
||||||
if generation_mode not in ("fast", "recon"):
|
|
||||||
raise ValueError("generationMode仅支持fast或recon")
|
|
||||||
return generation_mode
|
|
||||||
|
|
||||||
def _should_enable_reconnaissance(self, request_data, generation_mode):
|
|
||||||
if "enableReconnaissance" in request_data:
|
|
||||||
return bool(request_data.get("enableReconnaissance"))
|
|
||||||
return generation_mode == "recon"
|
|
||||||
|
|
||||||
def _get_max_attempts(self, request_data, generation_mode):
|
|
||||||
default_attempts = 1 if generation_mode == "fast" else 2
|
|
||||||
try:
|
|
||||||
max_attempts = int(request_data.get("maxAttempts", default_attempts))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise ValueError("maxAttempts必须是整数")
|
|
||||||
return max(1, min(max_attempts, 3))
|
|
||||||
|
|
||||||
def _build_effective_prompt(self, request_data):
|
|
||||||
prompt_parts = [self._normalize_text_list(request_data.get("prompt"))]
|
|
||||||
selectors = request_data.get("selectors")
|
|
||||||
test_data = request_data.get("testData")
|
|
||||||
if isinstance(selectors, dict) and selectors:
|
|
||||||
prompt_parts.append("前端传入的页面元素 selectors:\n{0}".format(json.dumps(selectors, ensure_ascii=False, indent=2)))
|
|
||||||
if isinstance(test_data, dict) and test_data:
|
|
||||||
prompt_parts.append("前端传入的测试数据 testData:\n{0}".format(json.dumps(test_data, ensure_ascii=False, indent=2)))
|
|
||||||
return "\n\n".join(part for part in prompt_parts if part)
|
|
||||||
|
|
||||||
def _build_response_data(self, request_data, verify_result, generation_mode):
|
|
||||||
return {
|
|
||||||
"projectId": request_data.get("projectId"),
|
|
||||||
"caseId": request_data.get("caseId"),
|
|
||||||
"automationType": request_data.get("automationType"),
|
|
||||||
"caseKey": request_data.get("caseKey"),
|
|
||||||
"content": self._get_case_name(request_data),
|
|
||||||
"generationMode": generation_mode,
|
|
||||||
"testcasePath": verify_result.get("testcasePath"),
|
|
||||||
"attempts": verify_result.get("attempts"),
|
|
||||||
"quality": verify_result.get("quality"),
|
|
||||||
"verificationStatus": verify_result.get("verificationStatus"),
|
|
||||||
"verificationMessage": verify_result.get("verificationMessage"),
|
|
||||||
"failureReason": verify_result.get("failureReason")
|
|
||||||
}
|
|
||||||
|
|
||||||
def _build_failure_code(self, verify_result):
|
|
||||||
verification_status = verify_result.get("verificationStatus")
|
|
||||||
if verification_status == "skipped":
|
|
||||||
return 3002
|
|
||||||
if verification_status == "timeout":
|
|
||||||
return 3004
|
|
||||||
return 3001
|
|
||||||
|
|
||||||
def _sanitize_error_message(self, message):
|
|
||||||
return str(message or "").replace("password", "******").replace("密码", "密码******")
|
|
||||||
|
|
||||||
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=8081):
|
|
||||||
server = ThreadingHTTPServer((host, port), GenerateAutomationHandler)
|
|
||||||
print("Create_ui_testcase HTTP服务已启动:http://{0}:{1}".format(host, port))
|
|
||||||
print("接口地址:POST {0}".format(API_PATH))
|
|
||||||
server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="UI自动化用例生成HTTP服务")
|
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="服务监听地址,默认0.0.0.0")
|
|
||||||
parser.add_argument("--port", default=8081, type=int, help="服务监听端口,默认8081")
|
|
||||||
args = parser.parse_args()
|
|
||||||
run_server(host=args.host, port=args.port)
|
|
||||||
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 55 KiB |
@@ -1,159 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
from playwright.sync_api import Page, expect
|
|
||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadAppPage(BasePage):
|
|
||||||
"""
|
|
||||||
Download the App 页面对象
|
|
||||||
|
|
||||||
已知侦察结果:
|
|
||||||
- 首页 URL: https://joyhub-website-frontend-test.best-envision.com/
|
|
||||||
- 页面标题: Joyhub | Explore Sexual Health, Wellness, and Connection
|
|
||||||
- 导航链接: Download the App
|
|
||||||
- Cookie 按钮文案: Accepet
|
|
||||||
"""
|
|
||||||
|
|
||||||
DOWNLOAD_APP_LINK_TEXT = re.compile(r"^Download the App$", re.I)
|
|
||||||
|
|
||||||
def accept_cookie_if_present(self):
|
|
||||||
# 页面侦察结果中按钮文本为 Accepet,疑似拼写如此,按真实页面处理
|
|
||||||
accept_button = self.page.get_by_role("button", name=re.compile(r"Accepet|Accept", re.I))
|
|
||||||
self.click_if_visible(accept_button, timeout=3000)
|
|
||||||
|
|
||||||
def open_download_app_page(self):
|
|
||||||
self.accept_cookie_if_present()
|
|
||||||
|
|
||||||
download_link = self.page.get_by_role("link", name=self.DOWNLOAD_APP_LINK_TEXT)
|
|
||||||
expect(download_link).to_be_visible(timeout=10000)
|
|
||||||
|
|
||||||
download_link.click()
|
|
||||||
try:
|
|
||||||
self.page.wait_for_load_state("networkidle", timeout=5000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_google_play_locator(self):
|
|
||||||
"""
|
|
||||||
优先使用稳定定位:
|
|
||||||
1. href 包含 play.google.com
|
|
||||||
2. 链接文本包含 Google Play
|
|
||||||
3. 图片 alt 包含 Google,向上找父级 a 标签
|
|
||||||
|
|
||||||
如果后续页面提供 data-testid,建议替换为:
|
|
||||||
self.page.get_by_test_id("google-play-download")
|
|
||||||
"""
|
|
||||||
candidates = [
|
|
||||||
self.page.locator("a[href*='play.google.com']").first,
|
|
||||||
self.page.get_by_role("link", name=re.compile(r"Google\s*Play", re.I)).first,
|
|
||||||
self.page.locator("a").filter(has_text=re.compile(r"Google\s*Play", re.I)).first,
|
|
||||||
self.page.locator("img[alt*='Google' i]").locator("xpath=ancestor::a[1]").first,
|
|
||||||
]
|
|
||||||
|
|
||||||
for locator in candidates:
|
|
||||||
try:
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def discover_google_play_href(self):
|
|
||||||
"""
|
|
||||||
DOM 兜底侦察:
|
|
||||||
当 Google Play 是图片按钮、无文本链接时,用 JS 从渲染后的 DOM 中提取跳转地址。
|
|
||||||
只用于兜底发现,不作为首选点击方式。
|
|
||||||
"""
|
|
||||||
return self.page.evaluate(
|
|
||||||
"""
|
|
||||||
() => {
|
|
||||||
const keywords = ['google play', 'play.google.com'];
|
|
||||||
const nodes = Array.from(document.querySelectorAll('a, button, [role="button"], img'));
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const text = [
|
|
||||||
node.innerText,
|
|
||||||
node.textContent,
|
|
||||||
node.getAttribute('aria-label'),
|
|
||||||
node.getAttribute('title'),
|
|
||||||
node.getAttribute('alt'),
|
|
||||||
node.getAttribute('href'),
|
|
||||||
node.getAttribute('src')
|
|
||||||
].filter(Boolean).join(' ').toLowerCase();
|
|
||||||
|
|
||||||
if (keywords.some(k => text.includes(k))) {
|
|
||||||
const link = node.closest('a');
|
|
||||||
if (link && link.href) {
|
|
||||||
return link.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.href) {
|
|
||||||
return node.href;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def click_google_play_and_get_redirect_url(self) -> str:
|
|
||||||
"""
|
|
||||||
点击 Google Play 下载入口,并返回跳转地址。
|
|
||||||
|
|
||||||
成功判定:
|
|
||||||
- 新开页面 URL
|
|
||||||
- 当前页面跳转后的 URL
|
|
||||||
- 或点击前已获取到 Google Play href
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.page.wait_for_load_state("networkidle", timeout=5000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
google_play_href = self.discover_google_play_href()
|
|
||||||
google_play_locator = self.get_google_play_locator()
|
|
||||||
|
|
||||||
if google_play_locator is None and not google_play_href:
|
|
||||||
raise AssertionError(
|
|
||||||
"未找到 Google Play 下载入口。"
|
|
||||||
"请检查 Download the App 页面是否存在 Google Play 链接,"
|
|
||||||
"或补充稳定 selector,例如 data-testid。"
|
|
||||||
)
|
|
||||||
|
|
||||||
context = self.page.context
|
|
||||||
before_pages = list(context.pages)
|
|
||||||
old_url = self.page.url
|
|
||||||
|
|
||||||
if google_play_locator is not None:
|
|
||||||
google_play_locator.scroll_into_view_if_needed()
|
|
||||||
google_play_locator.click()
|
|
||||||
else:
|
|
||||||
# 没有稳定可点击 locator 时,使用已发现 href 直接跳转。
|
|
||||||
# TODO: 页面增加稳定 selector 后,替换为 locator.click()
|
|
||||||
self.page.goto(google_play_href, wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
self.page.wait_for_timeout(3000)
|
|
||||||
|
|
||||||
after_pages = list(context.pages)
|
|
||||||
new_pages = [p for p in after_pages if p not in before_pages]
|
|
||||||
|
|
||||||
if new_pages:
|
|
||||||
new_page = new_pages[-1]
|
|
||||||
new_page.wait_for_load_state("domcontentloaded")
|
|
||||||
try:
|
|
||||||
new_page.wait_for_load_state("networkidle", timeout=10000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return new_page.url
|
|
||||||
|
|
||||||
if self.page.url != old_url:
|
|
||||||
return self.page.url
|
|
||||||
|
|
||||||
if google_play_href:
|
|
||||||
return google_play_href
|
|
||||||
|
|
||||||
raise AssertionError("点击 Google Play 后未获取到跳转地址")
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from playwright.sync_api import Locator, Page, TimeoutError as PlaywrightTimeoutError
|
|
||||||
|
|
||||||
|
|
||||||
class BasePage:
|
|
||||||
"""Playwright 页面基类:封装通用等待、点击、截图等稳定操作。"""
|
|
||||||
|
|
||||||
def __init__(self, page: Page):
|
|
||||||
self.page = page
|
|
||||||
|
|
||||||
def goto(self, url: str, timeout: int = 60000) -> None:
|
|
||||||
self.page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
|
|
||||||
def wait_for_network_idle(self, timeout: int = 30000) -> None:
|
|
||||||
try:
|
|
||||||
self.page.wait_for_load_state("networkidle", timeout=timeout)
|
|
||||||
except PlaywrightTimeoutError:
|
|
||||||
self.page.wait_for_load_state("domcontentloaded", timeout=timeout)
|
|
||||||
|
|
||||||
def wait_for_visible(self, locator: Locator, timeout: int = 10000) -> Locator:
|
|
||||||
locator.wait_for(state="visible", timeout=timeout)
|
|
||||||
return locator
|
|
||||||
|
|
||||||
def click_if_visible(self, locator: Locator, timeout: int = 3000) -> bool:
|
|
||||||
try:
|
|
||||||
if locator.first.is_visible(timeout=timeout):
|
|
||||||
locator.first.click()
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
def safe_click(self, locator: Locator, timeout: int = 10000) -> None:
|
|
||||||
self.wait_for_visible(locator, timeout=timeout)
|
|
||||||
locator.click()
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
|
|
||||||
def screenshot(self, screenshot_dir: str, file_name: str, full_page: bool = True) -> str:
|
|
||||||
os.makedirs(screenshot_dir, exist_ok=True)
|
|
||||||
screenshot_path = os.path.join(screenshot_dir, file_name)
|
|
||||||
self.page.screenshot(path=screenshot_path, full_page=full_page)
|
|
||||||
return screenshot_path
|
|
||||||
|
|
||||||
def current_url(self) -> str:
|
|
||||||
return self.page.url
|
|
||||||
|
|
||||||
def title(self) -> str:
|
|
||||||
return self.page.title()
|
|
||||||
|
|
||||||
def visible_text_contains(self, keyword: str) -> bool:
|
|
||||||
try:
|
|
||||||
return self.page.get_by_text(keyword, exact=False).first.is_visible(timeout=3000)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import random
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, expect
|
|
||||||
|
|
||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class BlogPage(BasePage):
|
|
||||||
"""
|
|
||||||
Joyhub Blog/内容页页面对象。
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- 页面侦察结果未发现明确的 Blog 导航入口;
|
|
||||||
- 已发现真实导航候选包含 Discover、Following;
|
|
||||||
- 因此进入 blog/内容列表时优先尝试 Blog,失败后使用 Discover 作为内容入口兜底。
|
|
||||||
"""
|
|
||||||
|
|
||||||
HOME_URL = "https://joyhub-website-frontend-test.best-envision.com/"
|
|
||||||
EXPECTED_HOME_TITLE = "Joyhub | Explore Sexual Health, Wellness, and Connection"
|
|
||||||
|
|
||||||
NAV_DISCOVER_LINK = "Discover"
|
|
||||||
|
|
||||||
def open_home_page(self) -> None:
|
|
||||||
self.goto(self.HOME_URL)
|
|
||||||
self.accept_cookie_if_present()
|
|
||||||
expect(self.page).to_have_title(self.EXPECTED_HOME_TITLE)
|
|
||||||
|
|
||||||
def enter_blog_page(self) -> str:
|
|
||||||
"""
|
|
||||||
进入 blog/内容页。
|
|
||||||
优先级:
|
|
||||||
1. Blog 链接:如果页面后续版本新增 Blog 导航,可直接命中;
|
|
||||||
2. Discover 链接:侦察结果中存在,作为当前可用内容入口;
|
|
||||||
3. /blog 直达:仅作为最后兜底,并在无法点击入口时使用。
|
|
||||||
"""
|
|
||||||
before_url = self.page.url
|
|
||||||
|
|
||||||
blog_locators = [
|
|
||||||
self.page.get_by_role("link", name="Blog"),
|
|
||||||
self.page.locator("a", has_text="Blog"),
|
|
||||||
self.page.get_by_role("link", name=self.NAV_DISCOVER_LINK),
|
|
||||||
self.page.locator("a", has_text=self.NAV_DISCOVER_LINK),
|
|
||||||
]
|
|
||||||
|
|
||||||
for locator in blog_locators:
|
|
||||||
try:
|
|
||||||
if locator.count() > 0 and locator.first.is_visible():
|
|
||||||
locator.first.click()
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
return self.page.url
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
fallback_blog_url = self.HOME_URL.rstrip("/") + "/blog"
|
|
||||||
self.goto(fallback_blog_url)
|
|
||||||
|
|
||||||
assert self.page.url != before_url or "/blog" in self.page.url.lower(), (
|
|
||||||
"未能进入 blog/内容页:页面未发现 Blog 入口,Discover 入口也不可点击。"
|
|
||||||
)
|
|
||||||
return self.page.url
|
|
||||||
|
|
||||||
def _get_candidate_blog_links(self) -> List[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
从渲染后的页面中提取可点击的 blog/content 候选链接。
|
|
||||||
|
|
||||||
不硬编码未知 selector,优先根据 href 语义筛选:
|
|
||||||
blog/article/post/discover/detail/content。
|
|
||||||
若无命中,则退化为 main 区域内可见链接,排除导航类链接。
|
|
||||||
"""
|
|
||||||
return self.page.evaluate(
|
|
||||||
"""
|
|
||||||
() => {
|
|
||||||
const navTexts = new Set([
|
|
||||||
'Home',
|
|
||||||
'Download the App',
|
|
||||||
'Rewards',
|
|
||||||
'Support',
|
|
||||||
'About Us',
|
|
||||||
'Discover',
|
|
||||||
'Following',
|
|
||||||
'Partnerships',
|
|
||||||
'FAQs',
|
|
||||||
'Login'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const normalize = value => (value || '').replace(/\\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
const links = Array.from(document.querySelectorAll('a[href]'))
|
|
||||||
.filter(a => {
|
|
||||||
const rect = a.getBoundingClientRect();
|
|
||||||
const style = window.getComputedStyle(a);
|
|
||||||
return rect.width > 0
|
|
||||||
&& rect.height > 0
|
|
||||||
&& style.visibility !== 'hidden'
|
|
||||||
&& style.display !== 'none';
|
|
||||||
})
|
|
||||||
.map((a, index) => {
|
|
||||||
const href = a.href || '';
|
|
||||||
const text = normalize(a.innerText || a.textContent || a.getAttribute('aria-label') || '');
|
|
||||||
const imgAlt = normalize(
|
|
||||||
Array.from(a.querySelectorAll('img'))
|
|
||||||
.map(img => img.alt)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
index,
|
|
||||||
href,
|
|
||||||
text,
|
|
||||||
imgAlt,
|
|
||||||
pathname: (() => {
|
|
||||||
try { return new URL(href).pathname.toLowerCase(); }
|
|
||||||
catch(e) { return ''; }
|
|
||||||
})()
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(item => item.href && !item.href.startsWith('javascript:'));
|
|
||||||
|
|
||||||
const semanticLinks = links.filter(item =>
|
|
||||||
/blog|article|post|discover|detail|content|story/i.test(item.href)
|
|
||||||
&& !navTexts.has(item.text)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (semanticLinks.length > 0) {
|
|
||||||
return semanticLinks;
|
|
||||||
}
|
|
||||||
|
|
||||||
return links.filter(item =>
|
|
||||||
!navTexts.has(item.text)
|
|
||||||
&& item.href !== window.location.href
|
|
||||||
&& !/#$/.test(item.href)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
def click_random_blog(self) -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
随机点击一个 blog/content 候选项。
|
|
||||||
点击后等待 URL 或页面内容变化。
|
|
||||||
"""
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
candidates = self._get_candidate_blog_links()
|
|
||||||
|
|
||||||
assert candidates, (
|
|
||||||
"未找到可点击的 blog/content 候选链接。"
|
|
||||||
"页面侦察结果缺少明确 blog 卡片 selector,请补充稳定定位,如 data-testid='blog-card'。"
|
|
||||||
)
|
|
||||||
|
|
||||||
candidate = random.choice(candidates)
|
|
||||||
before_url = self.page.url
|
|
||||||
|
|
||||||
locator = self.page.locator("a[href]").nth(candidate["index"])
|
|
||||||
locator.scroll_into_view_if_needed(timeout=5000)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self.page.expect_navigation(wait_until="domcontentloaded", timeout=8000):
|
|
||||||
locator.click()
|
|
||||||
except PlaywrightTimeoutError:
|
|
||||||
locator.click()
|
|
||||||
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
|
|
||||||
after_url = self.page.url
|
|
||||||
assert after_url != before_url or self.page.locator("main, article, body").first.is_visible(), (
|
|
||||||
f"点击 blog/content 候选项后页面未出现有效跳转或内容区域。候选链接:{candidate['href']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"clicked_href": candidate.get("href", ""),
|
|
||||||
"clicked_text": candidate.get("text") or candidate.get("imgAlt") or "",
|
|
||||||
"before_url": before_url,
|
|
||||||
"after_url": after_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
def assert_blog_content_page_loaded(self) -> None:
|
|
||||||
"""
|
|
||||||
断言已进入 blog/content 内容页。
|
|
||||||
因页面缺少稳定详情页 selector,这里采用内容区域可见的稳健断言。
|
|
||||||
"""
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
|
|
||||||
content_locator = self.page.locator("article, main, [role='main'], body").first
|
|
||||||
expect(content_locator).to_be_visible(timeout=10000)
|
|
||||||
|
|
||||||
body_text = self.page.locator("body").inner_text(timeout=10000).strip()
|
|
||||||
assert len(body_text) > 0, "blog/content 页面 body 内容为空,疑似跳转失败或页面未渲染完成。"
|
|
||||||
|
|
||||||
def capture_blog_content_screenshot(self, screenshot_dir: str, case_key: str) -> str:
|
|
||||||
return self.screenshot(
|
|
||||||
screenshot_dir=screenshot_dir,
|
|
||||||
file_name=f"{case_key}_blog_content.png",
|
|
||||||
full_page=True,
|
|
||||||
)
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import random
|
|
||||||
import re
|
|
||||||
from typing import Dict, List
|
|
||||||
|
|
||||||
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
|
|
||||||
|
|
||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class NewsPage(BasePage):
|
|
||||||
"""Joyhub News 相关页面对象。"""
|
|
||||||
|
|
||||||
COOKIE_ACCEPT_TEXT_PATTERN = re.compile(r"^(Accepet|Accept|I Accept|Agree|Got it)$", re.I)
|
|
||||||
NEWS_LINK_TEXT_PATTERN = re.compile(r"^News$", re.I)
|
|
||||||
NEWS_ROUTE = "/news"
|
|
||||||
|
|
||||||
def __init__(self, page: Page, base_url: str):
|
|
||||||
super().__init__(page)
|
|
||||||
self.base_url = base_url.rstrip("/")
|
|
||||||
|
|
||||||
def open_home(self) -> None:
|
|
||||||
self.goto(self.base_url)
|
|
||||||
|
|
||||||
def accept_cookie_if_present(self) -> None:
|
|
||||||
accept_button = self.page.get_by_role("button", name=self.COOKIE_ACCEPT_TEXT_PATTERN)
|
|
||||||
self.click_if_visible(accept_button, timeout=3000)
|
|
||||||
|
|
||||||
def enter_news_page(self) -> None:
|
|
||||||
news_link = self.page.get_by_role("link", name=self.NEWS_LINK_TEXT_PATTERN)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if news_link.first.is_visible(timeout=5000):
|
|
||||||
news_link.first.click()
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.goto(f"{self.base_url}{self.NEWS_ROUTE}")
|
|
||||||
|
|
||||||
def is_news_context(self) -> bool:
|
|
||||||
url = self.page.url.lower()
|
|
||||||
title = self.page.title().lower()
|
|
||||||
|
|
||||||
if "news" in url:
|
|
||||||
return True
|
|
||||||
if "news" in title:
|
|
||||||
return True
|
|
||||||
if self.visible_text_contains("News"):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _collect_clickable_news_candidates(self) -> List[Dict[str, str]]:
|
|
||||||
script = """
|
|
||||||
() => {
|
|
||||||
const navTexts = new Set([
|
|
||||||
'home',
|
|
||||||
'download the app',
|
|
||||||
'rewards',
|
|
||||||
'support',
|
|
||||||
'about us',
|
|
||||||
'discover',
|
|
||||||
'following',
|
|
||||||
'partnerships',
|
|
||||||
'faqs',
|
|
||||||
'login'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const currentUrl = new URL(window.location.href);
|
|
||||||
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
||||||
|
|
||||||
function isVisible(el) {
|
|
||||||
const style = window.getComputedStyle(el);
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
return style &&
|
|
||||||
style.visibility !== 'hidden' &&
|
|
||||||
style.display !== 'none' &&
|
|
||||||
rect.width > 0 &&
|
|
||||||
rect.height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasContentContainer(el) {
|
|
||||||
return !!el.closest(
|
|
||||||
'article, [class*="article" i], [class*="card" i], [class*="news" i], [class*="post" i], [class*="blog" i], [class*="item" i]'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBadProtocol(url) {
|
|
||||||
return ['javascript:', 'mailto:', 'tel:'].includes(url.protocol);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelySocial(url) {
|
|
||||||
const host = url.hostname.toLowerCase();
|
|
||||||
return [
|
|
||||||
'facebook.com',
|
|
||||||
'instagram.com',
|
|
||||||
'twitter.com',
|
|
||||||
'x.com',
|
|
||||||
'youtube.com',
|
|
||||||
'tiktok.com',
|
|
||||||
'linkedin.com'
|
|
||||||
].some(domain => host.includes(domain));
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
for (const a of anchors) {
|
|
||||||
if (!isVisible(a)) continue;
|
|
||||||
|
|
||||||
let url;
|
|
||||||
try {
|
|
||||||
url = new URL(a.href, window.location.origin);
|
|
||||||
} catch (e) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isBadProtocol(url)) continue;
|
|
||||||
if (isLikelySocial(url)) continue;
|
|
||||||
if (url.href === currentUrl.href) continue;
|
|
||||||
|
|
||||||
const text = (a.innerText || a.textContent || '').trim().replace(/\\s+/g, ' ');
|
|
||||||
const textLower = text.toLowerCase();
|
|
||||||
const pathLower = url.pathname.toLowerCase();
|
|
||||||
|
|
||||||
if (navTexts.has(textLower)) continue;
|
|
||||||
if (url.pathname === '/' || url.pathname === '') continue;
|
|
||||||
|
|
||||||
const hrefLooksLikeContent =
|
|
||||||
/news|article|blog|post|detail|story/i.test(pathLower) &&
|
|
||||||
pathLower !== '/news';
|
|
||||||
|
|
||||||
const containerLooksLikeContent = hasContentContainer(a);
|
|
||||||
|
|
||||||
if (hrefLooksLikeContent || containerLooksLikeContent) {
|
|
||||||
candidates.push({
|
|
||||||
href: url.href,
|
|
||||||
text: text,
|
|
||||||
path: url.pathname,
|
|
||||||
reason: hrefLooksLikeContent ? 'href_content_pattern' : 'content_container'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
return candidates.filter(item => {
|
|
||||||
if (seen.has(item.href)) return false;
|
|
||||||
seen.add(item.href);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
return self.page.evaluate(script)
|
|
||||||
|
|
||||||
def click_random_news_item(self) -> Dict[str, str]:
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
candidates = self._collect_clickable_news_candidates()
|
|
||||||
|
|
||||||
if not candidates:
|
|
||||||
raise AssertionError(
|
|
||||||
"未发现可点击的 news 内容候选。"
|
|
||||||
"请检查 News 页面是否加载成功,或为 news 卡片补充稳定 selector/data-testid。"
|
|
||||||
)
|
|
||||||
|
|
||||||
selected = random.choice(candidates)
|
|
||||||
href = selected["href"]
|
|
||||||
old_url = self.page.url
|
|
||||||
|
|
||||||
click_script = """
|
|
||||||
(targetHref) => {
|
|
||||||
const anchors = Array.from(document.querySelectorAll('a[href]'));
|
|
||||||
const target = anchors.find(a => {
|
|
||||||
try {
|
|
||||||
return new URL(a.href, window.location.origin).href === targetHref;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
throw new Error('Target news link not found: ' + targetHref);
|
|
||||||
}
|
|
||||||
|
|
||||||
target.scrollIntoView({block: 'center', inline: 'center'});
|
|
||||||
target.click();
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.page.evaluate(click_script, href)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.page.wait_for_url(lambda url: str(url) != old_url, timeout=15000)
|
|
||||||
except PlaywrightTimeoutError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.wait_for_network_idle()
|
|
||||||
return selected
|
|
||||||
|
|
||||||
def screenshot_news_content(self, screenshot_dir: str, file_name: str) -> str:
|
|
||||||
return self.screenshot(screenshot_dir=screenshot_dir, file_name=file_name, full_page=True)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
from dulizhan.test_case.Resource.UI.news_page import NewsPage
|
|
||||||
import os
|
|
||||||
|
|
||||||
import allure
|
|
||||||
import pytest
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from dulizhan.test_case.Resource.UI.news_page import NewsPage
|
|
||||||
|
|
||||||
|
|
||||||
CASE_INFO = {
|
|
||||||
"projectId": 1001,
|
|
||||||
"caseId": 2001,
|
|
||||||
"automationType": "ui",
|
|
||||||
"caseKey": "TC_dulizhan_ui_api_verify_001",
|
|
||||||
"moduleName": "news",
|
|
||||||
"productName": "",
|
|
||||||
"projectName": "dulizhan",
|
|
||||||
"caseName": "进入news页面,随机点击news,跳转到news内容后截图,就认为用例执行成功",
|
|
||||||
"pageUrl": "https://joyhub-website-frontend-test.best-envision.com/",
|
|
||||||
"screenshotDir": r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def browser():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
yield browser
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def page(browser):
|
|
||||||
context = browser.new_context(
|
|
||||||
viewport={"width": 1440, "height": 900},
|
|
||||||
ignore_https_errors=True,
|
|
||||||
)
|
|
||||||
page = context.new_page()
|
|
||||||
yield page
|
|
||||||
context.close()
|
|
||||||
|
|
||||||
|
|
||||||
@allure.feature(CASE_INFO["projectName"])
|
|
||||||
@allure.story(CASE_INFO["moduleName"])
|
|
||||||
@allure.title(CASE_INFO["caseName"])
|
|
||||||
def test_random_click_news_and_capture_content(page):
|
|
||||||
screenshot_dir = CASE_INFO["screenshotDir"]
|
|
||||||
os.makedirs(screenshot_dir, exist_ok=True)
|
|
||||||
|
|
||||||
news_page = NewsPage(page)
|
|
||||||
|
|
||||||
with allure.step("打开被测页面"):
|
|
||||||
news_page.open_home_page()
|
|
||||||
news_page.accept_cookie_if_present()
|
|
||||||
allure.attach(
|
|
||||||
news_page.current_url(),
|
|
||||||
name="首页 URL",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("进入 news 页面"):
|
|
||||||
news_page.enter_news_page()
|
|
||||||
allure.attach(
|
|
||||||
news_page.current_url(),
|
|
||||||
name="News 页面 URL",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("随机点击 news"):
|
|
||||||
selected_news = news_page.click_random_news()
|
|
||||||
allure.attach(
|
|
||||||
str(selected_news),
|
|
||||||
name="随机点击的 news 候选信息",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("截图跳转的 news 内容"):
|
|
||||||
news_page.assert_news_content_loaded()
|
|
||||||
screenshot_path = news_page.capture_news_content_screenshot(
|
|
||||||
screenshot_dir=screenshot_dir,
|
|
||||||
case_key=CASE_INFO["caseKey"],
|
|
||||||
)
|
|
||||||
allure.attach.file(
|
|
||||||
screenshot_path,
|
|
||||||
name="跳转后的 news 内容截图",
|
|
||||||
attachment_type=allure.attachment_type.PNG,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert os.path.exists(screenshot_path), f"news 内容截图未生成: {screenshot_path}"
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
from dulizhan.test_case.Resource.UI.blog_page import BlogPage
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import allure
|
|
||||||
import pytest
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from dulizhan.test_case.Resource.UI.blog_page import BlogPage
|
|
||||||
|
|
||||||
|
|
||||||
case_info = SimpleNamespace(
|
|
||||||
projectId=1001,
|
|
||||||
caseId=2001,
|
|
||||||
automationType="ui",
|
|
||||||
caseKey="TC_dulizhan_ui_api_verify_001",
|
|
||||||
moduleName="blog",
|
|
||||||
productName="",
|
|
||||||
projectName="dulizhan",
|
|
||||||
caseName="进入blog页面,随机点击blog,跳转到blog内容后截图,就认为用例执行成功",
|
|
||||||
screenshotDir=r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
|
|
||||||
pageUrl="https://joyhub-website-frontend-test.best-envision.com/",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def page():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
context = browser.new_context(
|
|
||||||
viewport={"width": 1440, "height": 900},
|
|
||||||
ignore_https_errors=True,
|
|
||||||
)
|
|
||||||
page = context.new_page()
|
|
||||||
yield page
|
|
||||||
context.close()
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
@allure.feature(case_info.projectName)
|
|
||||||
@allure.story(case_info.moduleName)
|
|
||||||
@allure.title(case_info.caseName)
|
|
||||||
def test_enter_blog_random_click_and_capture_content(page):
|
|
||||||
blog_page = BlogPage(page)
|
|
||||||
|
|
||||||
with allure.step("打开被测页面"):
|
|
||||||
blog_page.open_home_page()
|
|
||||||
assert page.url.startswith(case_info.pageUrl)
|
|
||||||
|
|
||||||
with allure.step("进入blog页面"):
|
|
||||||
entered_url = blog_page.enter_blog_page()
|
|
||||||
allure.attach(
|
|
||||||
entered_url,
|
|
||||||
name="进入blog页面后的URL",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("随机点击blog"):
|
|
||||||
click_result = blog_page.click_random_blog()
|
|
||||||
allure.attach(
|
|
||||||
str(click_result),
|
|
||||||
name="随机点击blog结果",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("截图跳转的blog内容"):
|
|
||||||
blog_page.assert_blog_content_page_loaded()
|
|
||||||
screenshot_path = blog_page.capture_blog_content_screenshot(
|
|
||||||
screenshot_dir=case_info.screenshotDir,
|
|
||||||
case_key=case_info.caseKey,
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(screenshot_path, "rb") as image_file:
|
|
||||||
allure.attach(
|
|
||||||
image_file.read(),
|
|
||||||
name="blog内容页截图",
|
|
||||||
attachment_type=allure.attachment_type.PNG,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert screenshot_path, "blog 内容页截图路径为空"
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from dulizhan.test_case.Resource.UI.base_page import BasePage
|
|
||||||
from dulizhan.test_case.Resource.UI.news_page import NewsPage
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import allure
|
|
||||||
import pytest
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
from dulizhan.test_case.Resource.UI.news_page import NewsPage
|
|
||||||
|
|
||||||
|
|
||||||
CASE_INFO = SimpleNamespace(
|
|
||||||
projectId=1001,
|
|
||||||
caseId=2001,
|
|
||||||
automationType="ui",
|
|
||||||
caseKey="TC_dulizhan_ui_api_verify_001",
|
|
||||||
moduleName="news",
|
|
||||||
productName="",
|
|
||||||
projectName="dulizhan",
|
|
||||||
caseName="进入news页面,随机点击news,跳转到news内容后截图,就认为用例执行成功",
|
|
||||||
pageUrl="https://joyhub-website-frontend-test.best-envision.com/",
|
|
||||||
screenshotDir=r"C:\Users\a\smart-management-auto-test\dulizhan\screenshots",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def browser():
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
yield browser
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def page(browser):
|
|
||||||
page = browser.new_page(
|
|
||||||
viewport={"width": 1440, "height": 1200},
|
|
||||||
ignore_https_errors=True,
|
|
||||||
)
|
|
||||||
yield page
|
|
||||||
page.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def case_info():
|
|
||||||
return CASE_INFO
|
|
||||||
|
|
||||||
|
|
||||||
@allure.feature(CASE_INFO.projectName)
|
|
||||||
@allure.story(CASE_INFO.moduleName)
|
|
||||||
@allure.title(CASE_INFO.caseName)
|
|
||||||
@pytest.mark.ui
|
|
||||||
def test_enter_news_random_click_and_screenshot(page, case_info):
|
|
||||||
news_page = NewsPage(page, case_info.pageUrl)
|
|
||||||
|
|
||||||
with allure.step("打开被测页面"):
|
|
||||||
news_page.open_home()
|
|
||||||
news_page.accept_cookie_if_present()
|
|
||||||
assert "Joyhub" in news_page.title(), f"首页标题不符合预期,当前标题:{news_page.title()}"
|
|
||||||
|
|
||||||
with allure.step("进入news页面"):
|
|
||||||
news_page.enter_news_page()
|
|
||||||
assert news_page.is_news_context(), (
|
|
||||||
f"未确认进入 news 页面上下文,当前URL:{page.url},当前标题:{page.title()}。"
|
|
||||||
"侦察结果未提供明确 News 导航 selector,如实际路由不是 /news,请调整 NewsPage.NEWS_ROUTE。"
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("随机点击news"):
|
|
||||||
before_click_url = page.url
|
|
||||||
selected_news = news_page.click_random_news_item()
|
|
||||||
allure.attach(
|
|
||||||
str(selected_news),
|
|
||||||
name="随机点击的 news 候选",
|
|
||||||
attachment_type=allure.attachment_type.TEXT,
|
|
||||||
)
|
|
||||||
assert page.url != before_click_url or selected_news.get("href"), (
|
|
||||||
f"点击 news 后页面未发生有效跳转,点击前URL:{before_click_url},点击后URL:{page.url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
with allure.step("截图跳转的news内容"):
|
|
||||||
screenshot_path = news_page.screenshot_news_content(
|
|
||||||
screenshot_dir=case_info.screenshotDir,
|
|
||||||
file_name=f"{case_info.caseKey}_news_content.png",
|
|
||||||
)
|
|
||||||
allure.attach.file(
|
|
||||||
screenshot_path,
|
|
||||||
name="跳转后的news内容截图",
|
|
||||||
attachment_type=allure.attachment_type.PNG,
|
|
||||||
)
|
|
||||||
assert screenshot_path, "news 内容截图保存失败"
|
|
||||||
33
joyhub_backend/test_case/TestCase/接口/conftest.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import allure_pytest.listener
|
||||||
|
except ImportError:
|
||||||
|
allure_pytest = None
|
||||||
|
|
||||||
|
|
||||||
|
def _allure_test_fixtures_compatible(item):
|
||||||
|
fixturemanager = item.session._fixturemanager
|
||||||
|
fixturedefs = []
|
||||||
|
|
||||||
|
if hasattr(item, "_request") and hasattr(item._request, "fixturenames"):
|
||||||
|
for name in item._request.fixturenames:
|
||||||
|
try:
|
||||||
|
fixturedefs_pytest = fixturemanager.getfixturedefs(name, item)
|
||||||
|
except AttributeError:
|
||||||
|
fixturedefs_pytest = fixturemanager.getfixturedefs(name, item.nodeid)
|
||||||
|
if fixturedefs_pytest:
|
||||||
|
fixturedefs.extend(fixturedefs_pytest)
|
||||||
|
|
||||||
|
return fixturedefs
|
||||||
|
|
||||||
|
|
||||||
|
if allure_pytest is not None:
|
||||||
|
allure_pytest.listener._test_fixtures = _allure_test_fixtures_compatible
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# -*- 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"
|
|
||||||
6
package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "smart-management-auto-test",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2026 Anthropic, PBC.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
***
|
|
||||||
|
|
||||||
name: webapp-testing
|
|
||||||
description: Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs.
|
|
||||||
license: Complete terms in LICENSE.txt
|
|
||||||
--------------------------------------
|
|
||||||
|
|
||||||
# Web Application Testing
|
|
||||||
|
|
||||||
To test local web applications, write native Python Playwright scripts.
|
|
||||||
|
|
||||||
**Helper Scripts Available**:
|
|
||||||
|
|
||||||
- `scripts/with_server.py` - Manages server lifecycle (supports multiple servers)
|
|
||||||
|
|
||||||
**Always run scripts with** **`--help`** **first** to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
|
|
||||||
|
|
||||||
## Decision Tree: Choosing Your Approach
|
|
||||||
|
|
||||||
```
|
|
||||||
User task → Is it static HTML?
|
|
||||||
├─ Yes → Read HTML file directly to identify selectors
|
|
||||||
│ ├─ Success → Write Playwright script using selectors
|
|
||||||
│ └─ Fails/Incomplete → Treat as dynamic (below)
|
|
||||||
│
|
|
||||||
└─ No (dynamic webapp) → Is the server already running?
|
|
||||||
├─ No → Run: python scripts/with_server.py --help
|
|
||||||
│ Then use the helper + write simplified Playwright script
|
|
||||||
│
|
|
||||||
└─ Yes → Reconnaissance-then-action:
|
|
||||||
1. Navigate and wait for networkidle
|
|
||||||
2. Take screenshot or inspect DOM
|
|
||||||
3. Identify selectors from rendered state
|
|
||||||
4. Execute actions with discovered selectors
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example: Using with\_server.py
|
|
||||||
|
|
||||||
To start a server, run `--help` first, then use the helper:
|
|
||||||
|
|
||||||
**Single server:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**Multiple servers (e.g., backend + frontend):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/with_server.py \
|
|
||||||
--server "cd backend && python server.py" --port 3000 \
|
|
||||||
--server "cd frontend && npm run dev" --port 5173 \
|
|
||||||
-- python your_automation.py
|
|
||||||
```
|
|
||||||
|
|
||||||
To create an automation script, include only Playwright logic (servers are managed automatically):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
|
|
||||||
page = browser.new_page()
|
|
||||||
page.goto('http://localhost:5173') # Server already running and ready
|
|
||||||
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
|
|
||||||
# ... your automation logic
|
|
||||||
browser.close()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reconnaissance-Then-Action Pattern
|
|
||||||
|
|
||||||
1. **Inspect rendered DOM**:
|
|
||||||
```python
|
|
||||||
page.screenshot(path='/tmp/inspect.png', full_page=True)
|
|
||||||
content = page.content()
|
|
||||||
page.locator('button').all()
|
|
||||||
```
|
|
||||||
2. **Identify selectors** from inspection results
|
|
||||||
3. **Execute actions** using discovered selectors
|
|
||||||
|
|
||||||
## Common Pitfall
|
|
||||||
|
|
||||||
❌ **Don't** inspect the DOM before waiting for `networkidle` on dynamic apps
|
|
||||||
✅ **Do** wait for `page.wait_for_load_state('networkidle')` before inspection
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
- **Use bundled scripts as black boxes** - To accomplish a task, consider whether one of the scripts available in `scripts/` can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use `--help` to see usage, then invoke directly.
|
|
||||||
- Use `sync_playwright()` for synchronous scripts
|
|
||||||
- Always close the browser when done
|
|
||||||
- Use descriptive selectors: `text=`, `role=`, CSS selectors, or IDs
|
|
||||||
- Add appropriate waits: `page.wait_for_selector()` or `page.wait_for_timeout()`
|
|
||||||
|
|
||||||
## Reference Files
|
|
||||||
|
|
||||||
- **examples/** - Examples showing common patterns:
|
|
||||||
- `element_discovery.py` - Discovering buttons, links, and inputs on a page
|
|
||||||
- `static_html_automation.py` - Using file:// URLs for local HTML
|
|
||||||
- `console_logging.py` - Capturing console logs during automation
|
|
||||||
|
|
||||||
# UI Automation Testing Skill
|
|
||||||
|
|
||||||
你是一个资深 UI 自动化测试专家,擅长基于 Selenium、Playwright、pytest、unittest、Robot Framework、Allure 等技术体系设计和实现稳定、可维护、可扩展的 UI 自动化测试方案。
|
|
||||||
|
|
||||||
## 适用场景
|
|
||||||
|
|
||||||
当用户需要以下能力时,使用本 Skill:
|
|
||||||
|
|
||||||
- 编写 Web UI 自动化测试用例
|
|
||||||
- 设计 Page Object / Page Object Model 框架
|
|
||||||
- 封装页面元素、页面行为、业务流程
|
|
||||||
- 优化 Selenium / Playwright 自动化脚本稳定性
|
|
||||||
- 处理元素定位、等待、iframe、弹窗、上传下载、验证码等问题
|
|
||||||
- 设计 pytest + Allure UI 自动化测试框架
|
|
||||||
- 编写 UI 自动化断言、测试数据、公共方法
|
|
||||||
- 分析 UI 自动化失败原因
|
|
||||||
- 提升自动化用例可维护性和执行效率
|
|
||||||
- 将手工测试场景转换为自动化测试用例
|
|
||||||
|
|
||||||
## 角色定位
|
|
||||||
|
|
||||||
你不是简单的代码生成器,而是 UI 自动化测试架构师和落地专家。
|
|
||||||
|
|
||||||
你需要:
|
|
||||||
|
|
||||||
1. 理解用户当前项目框架和代码风格;
|
|
||||||
2. 优先复用已有封装,不重复造轮子;
|
|
||||||
3. 保持用例稳定性、可读性和可维护性;
|
|
||||||
4. 按照自动化测试最佳实践设计代码;
|
|
||||||
5. 明确区分页面层、业务层、测试层;
|
|
||||||
6. 对不稳定写法主动给出风险提示;
|
|
||||||
7. 生成代码前先确认当前项目使用的技术栈和目录结构。
|
|
||||||
|
|
||||||
## 工作原则
|
|
||||||
|
|
||||||
### 1. 先理解项目
|
|
||||||
|
|
||||||
在编写代码前,优先查看以下内容:
|
|
||||||
|
|
||||||
- 项目目录结构
|
|
||||||
- requirements.txt / pyproject.toml / package.json
|
|
||||||
- conftest.py
|
|
||||||
- pytest.ini / setup.cfg / tox.ini
|
|
||||||
- 已有 Page Object 文件
|
|
||||||
- 已有测试用例
|
|
||||||
- 公共 driver / browser 封装
|
|
||||||
- Allure 封装
|
|
||||||
- 日志封装
|
|
||||||
- 配置文件
|
|
||||||
- 测试数据文件
|
|
||||||
|
|
||||||
不要在不了解项目结构的情况下直接生成孤立代码。
|
|
||||||
|
|
||||||
### 2. 分层设计
|
|
||||||
|
|
||||||
推荐使用以下结构:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tests/
|
|
||||||
test_xxx.py 测试用例层,只做流程编排和断言
|
|
||||||
|
|
||||||
pages/
|
|
||||||
xxx_page.py 页面对象层,封装元素和页面操作
|
|
||||||
|
|
||||||
flows/
|
|
||||||
xxx_flow.py 业务流程层,封装跨页面业务流程
|
|
||||||
|
|
||||||
common/
|
|
||||||
browser.py 浏览器/driver 管理
|
|
||||||
base_page.py 基础页面封装
|
|
||||||
wait.py 显式等待封装
|
|
||||||
logger.py 日志封装
|
|
||||||
assertions.py 断言封装
|
|
||||||
|
|
||||||
data/
|
|
||||||
xxx_data.py / xxx.yaml 测试数据
|
|
||||||
|
|
||||||
config/
|
|
||||||
config.py / env.yaml 环境配置
|
|
||||||
```
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
# Example: Capturing console logs during browser automation
|
|
||||||
|
|
||||||
url = 'http://localhost:5173' # Replace with your URL
|
|
||||||
|
|
||||||
console_logs = []
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
|
||||||
|
|
||||||
# Set up console log capture
|
|
||||||
def handle_console_message(msg):
|
|
||||||
console_logs.append(f"[{msg.type}] {msg.text}")
|
|
||||||
print(f"Console: [{msg.type}] {msg.text}")
|
|
||||||
|
|
||||||
page.on("console", handle_console_message)
|
|
||||||
|
|
||||||
# Navigate to page
|
|
||||||
page.goto(url)
|
|
||||||
page.wait_for_load_state('networkidle')
|
|
||||||
|
|
||||||
# Interact with the page (triggers console logs)
|
|
||||||
page.click('text=Dashboard')
|
|
||||||
page.wait_for_timeout(1000)
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
# Save console logs to file
|
|
||||||
with open('/mnt/user-data/outputs/console.log', 'w') as f:
|
|
||||||
f.write('\n'.join(console_logs))
|
|
||||||
|
|
||||||
print(f"\nCaptured {len(console_logs)} console messages")
|
|
||||||
print(f"Logs saved to: /mnt/user-data/outputs/console.log")
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
|
||||||
# Example: Discovering buttons and other elements on a page
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
page = browser.new_page()
|
|
||||||
|
|
||||||
# Navigate to page and wait for it to fully load
|
|
||||||
page.goto('http://localhost:5173')
|
|
||||||
page.wait_for_load_state('networkidle')
|
|
||||||
|
|
||||||
# Discover all buttons on the page
|
|
||||||
buttons = page.locator('button').all()
|
|
||||||
print(f"Found {len(buttons)} buttons:")
|
|
||||||
for i, button in enumerate(buttons):
|
|
||||||
text = button.inner_text() if button.is_visible() else "[hidden]"
|
|
||||||
print(f" [{i}] {text}")
|
|
||||||
|
|
||||||
# Discover links
|
|
||||||
links = page.locator('a[href]').all()
|
|
||||||
print(f"\nFound {len(links)} links:")
|
|
||||||
for link in links[:5]: # Show first 5
|
|
||||||
text = link.inner_text().strip()
|
|
||||||
href = link.get_attribute('href')
|
|
||||||
print(f" - {text} -> {href}")
|
|
||||||
|
|
||||||
# Discover input fields
|
|
||||||
inputs = page.locator('input, textarea, select').all()
|
|
||||||
print(f"\nFound {len(inputs)} input fields:")
|
|
||||||
for input_elem in inputs:
|
|
||||||
name = input_elem.get_attribute('name') or input_elem.get_attribute('id') or "[unnamed]"
|
|
||||||
input_type = input_elem.get_attribute('type') or 'text'
|
|
||||||
print(f" - {name} ({input_type})")
|
|
||||||
|
|
||||||
# Take screenshot for visual reference
|
|
||||||
page.screenshot(path='/tmp/page_discovery.png', full_page=True)
|
|
||||||
print("\nScreenshot saved to /tmp/page_discovery.png")
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
from playwright.sync_api import sync_playwright
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Example: Automating interaction with static HTML files using file:// URLs
|
|
||||||
|
|
||||||
html_file_path = os.path.abspath('path/to/your/file.html')
|
|
||||||
file_url = f'file://{html_file_path}'
|
|
||||||
|
|
||||||
with sync_playwright() as p:
|
|
||||||
browser = p.chromium.launch(headless=True)
|
|
||||||
page = browser.new_page(viewport={'width': 1920, 'height': 1080})
|
|
||||||
|
|
||||||
# Navigate to local HTML file
|
|
||||||
page.goto(file_url)
|
|
||||||
|
|
||||||
# Take screenshot
|
|
||||||
page.screenshot(path='/mnt/user-data/outputs/static_page.png', full_page=True)
|
|
||||||
|
|
||||||
# Interact with elements
|
|
||||||
page.click('text=Click Me')
|
|
||||||
page.fill('#name', 'John Doe')
|
|
||||||
page.fill('#email', 'john@example.com')
|
|
||||||
|
|
||||||
# Submit form
|
|
||||||
page.click('button[type="submit"]')
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# Take final screenshot
|
|
||||||
page.screenshot(path='/mnt/user-data/outputs/after_submit.png', full_page=True)
|
|
||||||
|
|
||||||
browser.close()
|
|
||||||
|
|
||||||
print("Static HTML automation completed!")
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Start one or more servers, wait for them to be ready, run a command, then clean up.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Single server
|
|
||||||
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
|
|
||||||
python scripts/with_server.py --server "npm start" --port 3000 -- python test.py
|
|
||||||
|
|
||||||
# Multiple servers
|
|
||||||
python scripts/with_server.py \
|
|
||||||
--server "cd backend && python server.py" --port 3000 \
|
|
||||||
--server "cd frontend && npm run dev" --port 5173 \
|
|
||||||
-- python test.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
import socket
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
def is_server_ready(port, timeout=30):
|
|
||||||
"""Wait for server to be ready by polling the port."""
|
|
||||||
start_time = time.time()
|
|
||||||
while time.time() - start_time < timeout:
|
|
||||||
try:
|
|
||||||
with socket.create_connection(('localhost', port), timeout=1):
|
|
||||||
return True
|
|
||||||
except (socket.error, ConnectionRefusedError):
|
|
||||||
time.sleep(0.5)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Run command with one or more servers')
|
|
||||||
parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)')
|
|
||||||
parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)')
|
|
||||||
parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)')
|
|
||||||
parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Remove the '--' separator if present
|
|
||||||
if args.command and args.command[0] == '--':
|
|
||||||
args.command = args.command[1:]
|
|
||||||
|
|
||||||
if not args.command:
|
|
||||||
print("Error: No command specified to run")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Parse server configurations
|
|
||||||
if len(args.servers) != len(args.ports):
|
|
||||||
print("Error: Number of --server and --port arguments must match")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
servers = []
|
|
||||||
for cmd, port in zip(args.servers, args.ports):
|
|
||||||
servers.append({'cmd': cmd, 'port': port})
|
|
||||||
|
|
||||||
server_processes = []
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Start all servers
|
|
||||||
for i, server in enumerate(servers):
|
|
||||||
print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
|
|
||||||
|
|
||||||
# Use shell=True to support commands with cd and &&
|
|
||||||
process = subprocess.Popen(
|
|
||||||
server['cmd'],
|
|
||||||
shell=True,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE
|
|
||||||
)
|
|
||||||
server_processes.append(process)
|
|
||||||
|
|
||||||
# Wait for this server to be ready
|
|
||||||
print(f"Waiting for server on port {server['port']}...")
|
|
||||||
if not is_server_ready(server['port'], timeout=args.timeout):
|
|
||||||
raise RuntimeError(f"Server failed to start on port {server['port']} within {args.timeout}s")
|
|
||||||
|
|
||||||
print(f"Server ready on port {server['port']}")
|
|
||||||
|
|
||||||
print(f"\nAll {len(servers)} server(s) ready")
|
|
||||||
|
|
||||||
# Run the command
|
|
||||||
print(f"Running: {' '.join(args.command)}\n")
|
|
||||||
result = subprocess.run(args.command)
|
|
||||||
sys.exit(result.returncode)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up all servers
|
|
||||||
print(f"\nStopping {len(server_processes)} server(s)...")
|
|
||||||
for i, process in enumerate(server_processes):
|
|
||||||
try:
|
|
||||||
process.terminate()
|
|
||||||
process.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
print(f"Server {i+1} stopped")
|
|
||||||
print("All servers stopped")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 66 KiB |
@@ -1,78 +0,0 @@
|
|||||||
import os
|
|
||||||
import allure
|
|
||||||
from playwright.sync_api import Page, Locator, expect
|
|
||||||
|
|
||||||
|
|
||||||
class BasePage:
|
|
||||||
"""公共页面基类:封装稳定等待、截图、候选定位器选择等通用能力。"""
|
|
||||||
|
|
||||||
def __init__(self, page: Page, screenshot_dir: str):
|
|
||||||
self.page = page
|
|
||||||
self.screenshot_dir = screenshot_dir
|
|
||||||
os.makedirs(self.screenshot_dir, exist_ok=True)
|
|
||||||
|
|
||||||
def goto(self, url: str):
|
|
||||||
self.page.goto(url, wait_until="domcontentloaded")
|
|
||||||
try:
|
|
||||||
self.page.wait_for_load_state("networkidle", timeout=5000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def screenshot(self, name: str) -> str:
|
|
||||||
path = os.path.join(self.screenshot_dir, name)
|
|
||||||
self.page.screenshot(path=path, full_page=True)
|
|
||||||
allure.attach.file(path, name=name, attachment_type=allure.attachment_type.PNG)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def first_visible_locator(
|
|
||||||
self,
|
|
||||||
selectors: list[str],
|
|
||||||
timeout: int = 5000,
|
|
||||||
description: str = "元素",
|
|
||||||
) -> Locator:
|
|
||||||
"""
|
|
||||||
从候选 CSS/XPath/Text selector 中返回第一个可见元素。
|
|
||||||
注意:页面侦察结果为空时,候选 selector 需在项目落地时根据真实 DOM 调整。
|
|
||||||
"""
|
|
||||||
last_error = None
|
|
||||||
for selector in selectors:
|
|
||||||
try:
|
|
||||||
locator = self.page.locator(selector).first
|
|
||||||
locator.wait_for(state="visible", timeout=timeout)
|
|
||||||
return locator
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = exc
|
|
||||||
|
|
||||||
self.screenshot(f"not_found_{description}.png")
|
|
||||||
raise AssertionError(f"未找到可见{description},请根据真实页面 DOM 更新 selector。候选:{selectors}") from last_error
|
|
||||||
|
|
||||||
def click_first_visible(
|
|
||||||
self,
|
|
||||||
selectors: list[str],
|
|
||||||
timeout: int = 5000,
|
|
||||||
description: str = "按钮",
|
|
||||||
):
|
|
||||||
locator = self.first_visible_locator(selectors, timeout=timeout, description=description)
|
|
||||||
locator.click()
|
|
||||||
|
|
||||||
def fill_first_visible(
|
|
||||||
self,
|
|
||||||
selectors: list[str],
|
|
||||||
value: str,
|
|
||||||
timeout: int = 5000,
|
|
||||||
description: str = "输入框",
|
|
||||||
):
|
|
||||||
locator = self.first_visible_locator(selectors, timeout=timeout, description=description)
|
|
||||||
locator.fill(value)
|
|
||||||
|
|
||||||
def wait_network_idle(self):
|
|
||||||
try:
|
|
||||||
self.page.wait_for_load_state("networkidle", timeout=5000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def assert_url_contains(self, expected_part: str):
|
|
||||||
expect(self.page).to_have_url(lambda url: expected_part in url)
|
|
||||||
|
|
||||||
def wait_for_timeout(self, ms: int):
|
|
||||||
self.page.wait_for_timeout(ms)
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
import re
|
|
||||||
from playwright.sync_api import expect
|
|
||||||
from zhyy.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class SmartManagementLoginPage(BasePage):
|
|
||||||
"""
|
|
||||||
智慧运营登录页对象。
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
当前未提供登录页侦察结果,以下 selector 为常见候选。
|
|
||||||
落地时请优先使用真实 DOM 中稳定属性,例如 data-testid、id、name。
|
|
||||||
"""
|
|
||||||
|
|
||||||
USERNAME_INPUT_SELECTORS = [
|
|
||||||
"input[name='username']",
|
|
||||||
"input[name='userName']",
|
|
||||||
"input[id*='username']",
|
|
||||||
"input[placeholder*='用户名']",
|
|
||||||
"input[placeholder*='账号']",
|
|
||||||
]
|
|
||||||
|
|
||||||
PASSWORD_INPUT_SELECTORS = [
|
|
||||||
"input[name='password']",
|
|
||||||
"input[type='password']",
|
|
||||||
"input[placeholder*='密码']",
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGIN_BUTTON_SELECTORS = [
|
|
||||||
"button:has-text('登录')",
|
|
||||||
"button:has-text('登 录')",
|
|
||||||
"[role='button']:has-text('登录')",
|
|
||||||
]
|
|
||||||
|
|
||||||
def login_if_needed(self, username: str, password: str):
|
|
||||||
"""
|
|
||||||
如果当前页面出现登录表单则登录;如果已登录或 SSO 已生效则跳过。
|
|
||||||
"""
|
|
||||||
username_input = self.page.locator(",".join(self.USERNAME_INPUT_SELECTORS)).first
|
|
||||||
try:
|
|
||||||
username_input.wait_for(state="visible", timeout=5000)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fill_first_visible(self.USERNAME_INPUT_SELECTORS, username, description="用户名输入框")
|
|
||||||
self.fill_first_visible(self.PASSWORD_INPUT_SELECTORS, password, description="密码输入框")
|
|
||||||
self.click_first_visible(self.LOGIN_BUTTON_SELECTORS, description="登录按钮")
|
|
||||||
self.wait_network_idle()
|
|
||||||
self.screenshot("after_login.png")
|
|
||||||
|
|
||||||
|
|
||||||
class ContractManagePage(BasePage):
|
|
||||||
"""
|
|
||||||
合同管理页面对象。
|
|
||||||
|
|
||||||
被测页面:
|
|
||||||
https://smart-management-web-st.best-envision.com/SZPurchase/PurchaseManage/PurchaseOrderManage
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
reconnaissanceResult 为空,缺少真实 DOM。
|
|
||||||
以下 selector 采用“清晰候选 + TODO”方式,落地执行前需通过页面侦察结果替换为真实稳定 selector。
|
|
||||||
"""
|
|
||||||
|
|
||||||
CONTRACT_MENU_SELECTORS = [
|
|
||||||
# TODO: 使用真实菜单 DOM 替换,例如 [data-testid='contract-menu']
|
|
||||||
"text=合同管理",
|
|
||||||
"a:has-text('合同管理')",
|
|
||||||
"li:has-text('合同管理')",
|
|
||||||
"[role='menuitem']:has-text('合同管理')",
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTRACT_NO_INPUT_SELECTORS = [
|
|
||||||
# TODO: 使用真实合同编号输入框 selector 替换
|
|
||||||
"input[placeholder*='合同编号']",
|
|
||||||
"input[aria-label*='合同编号']",
|
|
||||||
".ant-form-item:has-text('合同编号') input",
|
|
||||||
".el-form-item:has-text('合同编号') input",
|
|
||||||
"label:has-text('合同编号') + div input",
|
|
||||||
]
|
|
||||||
|
|
||||||
QUERY_BUTTON_SELECTORS = [
|
|
||||||
"button:has-text('查询')",
|
|
||||||
"button:has-text('搜索')",
|
|
||||||
"[role='button']:has-text('查询')",
|
|
||||||
"[role='button']:has-text('搜索')",
|
|
||||||
]
|
|
||||||
|
|
||||||
TABLE_ROW_SELECTORS = [
|
|
||||||
# Ant Design Table
|
|
||||||
".ant-table-tbody > tr:not(.ant-table-placeholder)",
|
|
||||||
# Element Plus / Element UI Table
|
|
||||||
".el-table__body tbody tr",
|
|
||||||
# 原生 table
|
|
||||||
"table tbody tr",
|
|
||||||
]
|
|
||||||
|
|
||||||
EMPTY_SELECTORS = [
|
|
||||||
".ant-empty",
|
|
||||||
".el-empty",
|
|
||||||
"text=暂无数据",
|
|
||||||
"text=无数据",
|
|
||||||
"text=No Data",
|
|
||||||
]
|
|
||||||
|
|
||||||
def open(self, url: str):
|
|
||||||
self.goto(url)
|
|
||||||
self.screenshot("contract_manage_opened.png")
|
|
||||||
|
|
||||||
def open_contract_menu_if_visible(self):
|
|
||||||
"""
|
|
||||||
用户步骤要求“找到合同菜单”。
|
|
||||||
如果当前 URL 已直达合同管理页,则菜单可能无需点击。
|
|
||||||
若菜单可见则点击;不可见不强制失败,避免直达 URL 场景误报。
|
|
||||||
"""
|
|
||||||
for selector in self.CONTRACT_MENU_SELECTORS:
|
|
||||||
locator = self.page.locator(selector).first
|
|
||||||
try:
|
|
||||||
locator.wait_for(state="visible", timeout=2000)
|
|
||||||
locator.click()
|
|
||||||
self.wait_network_idle()
|
|
||||||
self.screenshot("contract_menu_clicked.png")
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def search_by_contract_no_keyword(self, keyword: str):
|
|
||||||
self.fill_first_visible(
|
|
||||||
self.CONTRACT_NO_INPUT_SELECTORS,
|
|
||||||
keyword,
|
|
||||||
description="合同编号搜索框",
|
|
||||||
)
|
|
||||||
self.click_first_visible(
|
|
||||||
self.QUERY_BUTTON_SELECTORS,
|
|
||||||
description="查询按钮",
|
|
||||||
)
|
|
||||||
self.wait_network_idle()
|
|
||||||
self.wait_for_timeout(1000)
|
|
||||||
self.screenshot("contract_no_search_result.png")
|
|
||||||
|
|
||||||
def _table_rows_locator(self):
|
|
||||||
for selector in self.TABLE_ROW_SELECTORS:
|
|
||||||
rows = self.page.locator(selector)
|
|
||||||
try:
|
|
||||||
if rows.count() > 0 and rows.first.is_visible(timeout=2000):
|
|
||||||
return rows
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
def has_empty_result(self) -> bool:
|
|
||||||
for selector in self.EMPTY_SELECTORS:
|
|
||||||
try:
|
|
||||||
locator = self.page.locator(selector).first
|
|
||||||
if locator.is_visible(timeout=1000):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_result_row_texts(self) -> list[str]:
|
|
||||||
rows = self._table_rows_locator()
|
|
||||||
if rows is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
row_texts = []
|
|
||||||
count = rows.count()
|
|
||||||
for index in range(count):
|
|
||||||
row = rows.nth(index)
|
|
||||||
text = re.sub(r"\s+", " ", row.inner_text()).strip()
|
|
||||||
if text:
|
|
||||||
row_texts.append(text)
|
|
||||||
return row_texts
|
|
||||||
|
|
||||||
def assert_contract_no_fuzzy_match(self, keyword: str):
|
|
||||||
"""
|
|
||||||
断言查询结果与合同编号模糊匹配条件一致。
|
|
||||||
|
|
||||||
说明:
|
|
||||||
由于缺少真实表格列 selector,当前按整行文本包含 keyword 断言。
|
|
||||||
落地后建议替换为“合同编号列单元格”精确读取:
|
|
||||||
- AntD: 根据 th 文本定位列 index,再读取 tbody tr td[index]
|
|
||||||
- Element: 使用列 prop 或 class 定位
|
|
||||||
"""
|
|
||||||
row_texts = self.get_result_row_texts()
|
|
||||||
|
|
||||||
assert row_texts, (
|
|
||||||
f"合同编号关键字【{keyword}】查询后未获取到表格数据。"
|
|
||||||
f"若业务允许无结果,请调整测试数据为存在的合同编号片段。"
|
|
||||||
)
|
|
||||||
|
|
||||||
unmatched_rows = [text for text in row_texts if keyword not in text]
|
|
||||||
assert not unmatched_rows, (
|
|
||||||
f"存在与合同编号关键字【{keyword}】不匹配的查询结果:{unmatched_rows}"
|
|
||||||
)
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import re
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import allure
|
|
||||||
from playwright.sync_api import Page, expect
|
|
||||||
|
|
||||||
from zhyy.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class ContractManagementPage(BasePage):
|
|
||||||
"""合同管理页面对象。
|
|
||||||
|
|
||||||
注意:
|
|
||||||
- 当前未提供页面侦察结果,以下 selector 采用“候选定位 + TODO”方式。
|
|
||||||
- 落地执行前,建议通过侦察脚本确认真实 DOM 后替换 TODO selector。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, page: Page, screenshot_dir: str):
|
|
||||||
super().__init__(page, screenshot_dir)
|
|
||||||
|
|
||||||
def open(self, url: str) -> None:
|
|
||||||
with allure.step("打开合同管理被测页面"):
|
|
||||||
self.goto(url)
|
|
||||||
self.attach_screenshot("01_open_contract_management_page.png")
|
|
||||||
|
|
||||||
def login_if_needed(self, username: str, password: str) -> None:
|
|
||||||
"""如页面跳转到登录页,则执行登录。
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
请根据真实登录页 DOM 替换用户名、密码、登录按钮 selector。
|
|
||||||
当前保留常见中文系统候选定位,避免在无侦察结果时硬编码单一 selector。
|
|
||||||
"""
|
|
||||||
with allure.step("如需要则登录系统"):
|
|
||||||
username_candidates = [
|
|
||||||
self.page.get_by_placeholder(re.compile("用户名|账号|请输入用户名|请输入账号")),
|
|
||||||
self.page.locator("input[name='username']"),
|
|
||||||
self.page.locator("input[type='text']").first,
|
|
||||||
# TODO: 替换为真实用户名输入框 selector,例如:self.page.locator("#username")
|
|
||||||
]
|
|
||||||
|
|
||||||
password_candidates = [
|
|
||||||
self.page.get_by_placeholder(re.compile("密码|请输入密码")),
|
|
||||||
self.page.locator("input[name='password']"),
|
|
||||||
self.page.locator("input[type='password']"),
|
|
||||||
# TODO: 替换为真实密码输入框 selector,例如:self.page.locator("#password")
|
|
||||||
]
|
|
||||||
|
|
||||||
login_button_candidates = [
|
|
||||||
self.page.get_by_role("button", name=re.compile("登录|登 录|Login", re.I)),
|
|
||||||
self.page.locator("button[type='submit']"),
|
|
||||||
self.page.locator("text=登录"),
|
|
||||||
# TODO: 替换为真实登录按钮 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
login_form_visible = any(
|
|
||||||
locator.first.is_visible()
|
|
||||||
for locator in username_candidates + password_candidates
|
|
||||||
)
|
|
||||||
|
|
||||||
if not login_form_visible:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fill_first_visible(username_candidates, username, description="用户名输入框")
|
|
||||||
self.fill_first_visible(password_candidates, password, description="密码输入框")
|
|
||||||
self.attach_screenshot("02_before_login.png")
|
|
||||||
|
|
||||||
self.click_first_visible(login_button_candidates, description="登录按钮")
|
|
||||||
self.wait_network_idle()
|
|
||||||
self.attach_screenshot("03_after_login.png")
|
|
||||||
|
|
||||||
def ensure_contract_menu_selected(self) -> None:
|
|
||||||
"""进入/确认合同菜单。
|
|
||||||
|
|
||||||
若 URL 已直接进入合同管理页面,本方法不会强制点击菜单。
|
|
||||||
TODO:
|
|
||||||
如系统必须通过左侧菜单进入,请用页面真实菜单 selector 替换候选定位。
|
|
||||||
"""
|
|
||||||
with allure.step("找到并选择合同菜单"):
|
|
||||||
menu_candidates = [
|
|
||||||
self.page.get_by_role("menuitem", name=re.compile("合同管理|合同")),
|
|
||||||
self.page.get_by_text(re.compile("^合同管理$|^合同$"), exact=False),
|
|
||||||
self.page.locator("text=合同管理"),
|
|
||||||
self.page.locator("text=合同"),
|
|
||||||
# TODO: 替换为真实合同菜单 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
menu = self.first_visible_locator(
|
|
||||||
menu_candidates,
|
|
||||||
timeout=3000,
|
|
||||||
description="合同菜单",
|
|
||||||
)
|
|
||||||
menu.click()
|
|
||||||
self.wait_network_idle()
|
|
||||||
except AssertionError:
|
|
||||||
# 直接 URL 进入时可能没有可见菜单或菜单已选中,不阻断用例。
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.attach_screenshot("04_contract_menu_selected.png")
|
|
||||||
|
|
||||||
def search_by_contract_no_fuzzy(self, partial_contract_no: str) -> None:
|
|
||||||
"""按合同编号进行模糊查询。"""
|
|
||||||
with allure.step(f"输入合同编号部分字符并点击查询:{partial_contract_no}"):
|
|
||||||
contract_no_input_candidates = [
|
|
||||||
self.page.get_by_placeholder(re.compile("合同编号|请输入合同编号")),
|
|
||||||
self.page.locator("input[placeholder*='合同编号']"),
|
|
||||||
self.page.locator("label:has-text('合同编号')").locator("xpath=following::input[1]"),
|
|
||||||
self.page.locator("text=合同编号").locator("xpath=following::input[1]"),
|
|
||||||
# TODO: 替换为真实合同编号搜索框 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
query_button_candidates = [
|
|
||||||
self.page.get_by_role("button", name=re.compile("^查询$|搜索")),
|
|
||||||
self.page.locator("button:has-text('查询')"),
|
|
||||||
self.page.locator("text=查询"),
|
|
||||||
# TODO: 替换为真实查询按钮 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
self.fill_first_visible(
|
|
||||||
contract_no_input_candidates,
|
|
||||||
partial_contract_no,
|
|
||||||
description="合同编号搜索框",
|
|
||||||
)
|
|
||||||
self.attach_screenshot("05_filled_contract_no.png")
|
|
||||||
|
|
||||||
self.click_first_visible(
|
|
||||||
query_button_candidates,
|
|
||||||
description="查询按钮",
|
|
||||||
)
|
|
||||||
self.wait_network_idle()
|
|
||||||
|
|
||||||
# 等待常见表格渲染完成
|
|
||||||
self.wait_for_result_table()
|
|
||||||
self.attach_screenshot("06_after_contract_no_search.png")
|
|
||||||
|
|
||||||
def wait_for_result_table(self) -> None:
|
|
||||||
"""等待查询结果表格出现。
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
根据真实表格框架替换为唯一稳定 selector。
|
|
||||||
"""
|
|
||||||
table_candidates = [
|
|
||||||
self.page.locator(".el-table__body-wrapper"),
|
|
||||||
self.page.locator(".el-table__body"),
|
|
||||||
self.page.locator(".ant-table-tbody"),
|
|
||||||
self.page.locator("table tbody"),
|
|
||||||
self.page.locator("[role='table']"),
|
|
||||||
# TODO: 替换为真实结果表格 selector
|
|
||||||
]
|
|
||||||
self.first_visible_locator(table_candidates, timeout=10000, description="合同结果表格")
|
|
||||||
|
|
||||||
def result_rows_locator(self):
|
|
||||||
"""结果表格行候选。
|
|
||||||
|
|
||||||
TODO:
|
|
||||||
建议替换为合同列表真实数据行 selector。
|
|
||||||
"""
|
|
||||||
candidates = [
|
|
||||||
self.page.locator(".el-table__body tbody tr"),
|
|
||||||
self.page.locator(".ant-table-tbody tr"),
|
|
||||||
self.page.locator("table tbody tr"),
|
|
||||||
self.page.locator("[role='row']"),
|
|
||||||
# TODO: 替换为真实合同列表行 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
for locator in candidates:
|
|
||||||
try:
|
|
||||||
if locator.count() > 0:
|
|
||||||
return locator
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
raise AssertionError("未找到合同列表数据行,请根据真实页面补充 result_rows_locator selector。")
|
|
||||||
|
|
||||||
def get_result_row_texts(self) -> list[str]:
|
|
||||||
with allure.step("获取合同列表查询结果"):
|
|
||||||
rows = self.result_rows_locator()
|
|
||||||
texts = self.text_contents(rows)
|
|
||||||
# 过滤空行、表头、加载占位行
|
|
||||||
return [
|
|
||||||
text
|
|
||||||
for text in texts
|
|
||||||
if text and "暂无数据" not in text and "No Data" not in text
|
|
||||||
]
|
|
||||||
|
|
||||||
def assert_contract_no_fuzzy_match(self, partial_contract_no: str) -> None:
|
|
||||||
"""断言查询结果与合同编号模糊查询条件一致。
|
|
||||||
|
|
||||||
当前无页面侦察结果,无法确认“合同编号”列的真实 selector。
|
|
||||||
因此先断言每条结果行文本中包含输入的合同编号部分字符。
|
|
||||||
如果后续确认合同编号列 selector,请改为仅校验合同编号列文本。
|
|
||||||
"""
|
|
||||||
with allure.step("断言列表仅展示合同编号与输入内容模糊匹配的数据"):
|
|
||||||
row_texts = self.get_result_row_texts()
|
|
||||||
|
|
||||||
assert row_texts, "查询结果为空,无法验证合同编号模糊匹配。请确认测试数据或查询条件。"
|
|
||||||
|
|
||||||
unmatched_rows = [
|
|
||||||
row_text
|
|
||||||
for row_text in row_texts
|
|
||||||
if partial_contract_no not in row_text
|
|
||||||
]
|
|
||||||
|
|
||||||
assert not unmatched_rows, (
|
|
||||||
f"存在与合同编号查询条件不匹配的结果。\n"
|
|
||||||
f"查询条件:{partial_contract_no}\n"
|
|
||||||
f"不匹配行:{unmatched_rows}"
|
|
||||||
)
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import allure
|
|
||||||
from playwright.sync_api import Page, expect
|
|
||||||
|
|
||||||
from zhyy.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class SmartManagementLoginPage(BasePage):
|
|
||||||
"""智慧运营登录页。"""
|
|
||||||
|
|
||||||
# TODO:如项目已有页面侦察结果,请替换为真实 selector
|
|
||||||
USERNAME_INPUT_SELECTORS = [
|
|
||||||
'input[name="username"]',
|
|
||||||
'input[id="username"]',
|
|
||||||
'input[placeholder*="用户名"]',
|
|
||||||
'input[placeholder*="账号"]',
|
|
||||||
'input[type="text"]',
|
|
||||||
]
|
|
||||||
|
|
||||||
PASSWORD_INPUT_SELECTORS = [
|
|
||||||
'input[name="password"]',
|
|
||||||
'input[id="password"]',
|
|
||||||
'input[placeholder*="密码"]',
|
|
||||||
'input[type="password"]',
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGIN_BUTTON_SELECTORS = [
|
|
||||||
'button:has-text("登录")',
|
|
||||||
'button:has-text("登 录")',
|
|
||||||
'button[type="submit"]',
|
|
||||||
]
|
|
||||||
|
|
||||||
def is_login_page(self) -> bool:
|
|
||||||
url = self.page.url.lower()
|
|
||||||
if "login" in url:
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
self.first_visible_locator(self.PASSWORD_INPUT_SELECTORS, timeout=2000, description="密码输入框")
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@allure.step("登录智慧运营系统")
|
|
||||||
def login_if_required(self, username: str, password: str):
|
|
||||||
if not self.is_login_page():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fill_first_visible(self.USERNAME_INPUT_SELECTORS, username, description="用户名输入框")
|
|
||||||
self.fill_first_visible(self.PASSWORD_INPUT_SELECTORS, password, description="密码输入框")
|
|
||||||
self.click_first_visible(self.LOGIN_BUTTON_SELECTORS, description="登录按钮")
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
|
|
||||||
|
|
||||||
class ContractManagementPage(BasePage):
|
|
||||||
"""合同管理页面:封装合同编号模糊查询能力。"""
|
|
||||||
|
|
||||||
PAGE_URL = "https://smart-management-web-st.best-envision.com/SZPurchase/PurchaseManage/PurchaseOrderManage"
|
|
||||||
|
|
||||||
# TODO:页面侦察结果为空,以下为基于业务语义的候选定位;落地时建议替换为真实稳定 selector/data-testid
|
|
||||||
CONTRACT_MENU_SELECTORS = [
|
|
||||||
'a:has-text("合同管理")',
|
|
||||||
'li:has-text("合同管理")',
|
|
||||||
'span:has-text("合同管理")',
|
|
||||||
'div:has-text("合同管理")',
|
|
||||||
]
|
|
||||||
|
|
||||||
CONTRACT_NO_INPUT_SELECTORS = [
|
|
||||||
# Ant Design 常见结构:label 为“合同编号”后紧邻输入框
|
|
||||||
'xpath=//*[normalize-space()="合同编号"]/following::input[1]',
|
|
||||||
'input[placeholder*="合同编号"]',
|
|
||||||
'input[name*="contract"]',
|
|
||||||
'input[id*="contract"]',
|
|
||||||
# TODO:补充页面侦察得到的合同编号输入框 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
QUERY_BUTTON_SELECTORS = [
|
|
||||||
'button:has-text("查询")',
|
|
||||||
'button:has-text("搜索")',
|
|
||||||
# TODO:补充页面侦察得到的查询按钮 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
RESET_BUTTON_SELECTORS = [
|
|
||||||
'button:has-text("重置")',
|
|
||||||
'button:has-text("清空")',
|
|
||||||
]
|
|
||||||
|
|
||||||
TABLE_ROW_SELECTORS = [
|
|
||||||
# Ant Design 表格常见结构
|
|
||||||
'.ant-table-tbody > tr:not(.ant-table-placeholder)',
|
|
||||||
'table tbody tr',
|
|
||||||
# TODO:补充页面侦察得到的列表行 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
TABLE_HEADER_CELL_SELECTORS = [
|
|
||||||
'.ant-table-thead th',
|
|
||||||
'table thead th',
|
|
||||||
# TODO:补充页面侦察得到的表头 selector
|
|
||||||
]
|
|
||||||
|
|
||||||
EMPTY_SELECTORS = [
|
|
||||||
'.ant-empty',
|
|
||||||
'text=暂无数据',
|
|
||||||
'text=无数据',
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, page: Page):
|
|
||||||
super().__init__(page)
|
|
||||||
self.login_page = SmartManagementLoginPage(page)
|
|
||||||
|
|
||||||
@allure.step("打开合同管理页面")
|
|
||||||
def open(self, username: str, password: str):
|
|
||||||
self.goto(self.PAGE_URL)
|
|
||||||
self.login_page.login_if_required(username, password)
|
|
||||||
|
|
||||||
# 登录后如回到首页,则再次进入目标页面
|
|
||||||
if "PurchaseOrderManage" not in self.page.url:
|
|
||||||
self.goto(self.PAGE_URL)
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
|
|
||||||
self.ensure_contract_management_page_loaded()
|
|
||||||
|
|
||||||
@allure.step("确认合同管理页面加载完成")
|
|
||||||
def ensure_contract_management_page_loaded(self):
|
|
||||||
"""
|
|
||||||
优先确认页面 URL,其次确认页面上存在合同相关筛选项。
|
|
||||||
"""
|
|
||||||
if "PurchaseOrderManage" in self.page.url:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.first_visible_locator(self.CONTRACT_MENU_SELECTORS, timeout=5000, description="合同管理菜单")
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
raise AssertionError(f"合同管理页面未成功加载,当前 URL={self.page.url}") from exc
|
|
||||||
|
|
||||||
@allure.step("点击合同管理菜单")
|
|
||||||
def click_contract_menu_if_visible(self):
|
|
||||||
"""
|
|
||||||
用户步骤要求“找到合同菜单”;若当前已在目标页面,则不强制点击。
|
|
||||||
"""
|
|
||||||
if "PurchaseOrderManage" in self.page.url:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.click_first_visible(self.CONTRACT_MENU_SELECTORS, timeout=5000, description="合同管理菜单")
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
|
|
||||||
@allure.step("输入合同编号查询条件:{contract_no_keyword}")
|
|
||||||
def input_contract_no_keyword(self, contract_no_keyword: str):
|
|
||||||
try:
|
|
||||||
self.page.get_by_label("合同编号", exact=False).fill(contract_no_keyword)
|
|
||||||
except Exception:
|
|
||||||
self.fill_first_visible(
|
|
||||||
self.CONTRACT_NO_INPUT_SELECTORS,
|
|
||||||
contract_no_keyword,
|
|
||||||
description="合同编号搜索框",
|
|
||||||
)
|
|
||||||
|
|
||||||
@allure.step("点击查询按钮")
|
|
||||||
def click_query(self):
|
|
||||||
try:
|
|
||||||
self.page.get_by_role("button", name="查询").click()
|
|
||||||
self.wait_for_page_ready()
|
|
||||||
except Exception:
|
|
||||||
self.click_first_visible(self.QUERY_BUTTON_SELECTORS, description="查询按钮")
|
|
||||||
|
|
||||||
# 等待列表刷新完成
|
|
||||||
self.page.wait_for_timeout(1000)
|
|
||||||
|
|
||||||
@allure.step("按合同编号模糊查询:{contract_no_keyword}")
|
|
||||||
def search_by_contract_no(self, contract_no_keyword: str):
|
|
||||||
self.input_contract_no_keyword(contract_no_keyword)
|
|
||||||
self.click_query()
|
|
||||||
|
|
||||||
def _get_contract_no_column_index(self) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
根据表头文本动态识别“合同编号”列索引。
|
|
||||||
识别失败时返回 None,断言时退化为行文本包含校验。
|
|
||||||
"""
|
|
||||||
for selector in self.TABLE_HEADER_CELL_SELECTORS:
|
|
||||||
headers = self.page.locator(selector)
|
|
||||||
try:
|
|
||||||
count = headers.count()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for index in range(count):
|
|
||||||
text = headers.nth(index).inner_text(timeout=2000).strip()
|
|
||||||
if "合同编号" in text:
|
|
||||||
return index
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_result_rows_text(self) -> List[str]:
|
|
||||||
for selector in self.TABLE_ROW_SELECTORS:
|
|
||||||
rows = self.page.locator(selector)
|
|
||||||
try:
|
|
||||||
count = rows.count()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
row_texts = []
|
|
||||||
for index in range(count):
|
|
||||||
row = rows.nth(index)
|
|
||||||
text = row.inner_text(timeout=3000).strip()
|
|
||||||
if text:
|
|
||||||
row_texts.append(text)
|
|
||||||
if row_texts:
|
|
||||||
return row_texts
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_result_contract_numbers(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
返回结果列表中的合同编号列文本。
|
|
||||||
若无法识别合同编号列,则返回空列表,由上层断言决定是否退化为行文本校验。
|
|
||||||
"""
|
|
||||||
column_index = self._get_contract_no_column_index()
|
|
||||||
if column_index is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
for selector in self.TABLE_ROW_SELECTORS:
|
|
||||||
rows = self.page.locator(selector)
|
|
||||||
try:
|
|
||||||
row_count = rows.count()
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
contract_numbers = []
|
|
||||||
for row_index in range(row_count):
|
|
||||||
row = rows.nth(row_index)
|
|
||||||
cells = row.locator("td")
|
|
||||||
if cells.count() > column_index:
|
|
||||||
contract_no = cells.nth(column_index).inner_text(timeout=3000).strip()
|
|
||||||
if contract_no:
|
|
||||||
contract_numbers.append(contract_no)
|
|
||||||
|
|
||||||
if contract_numbers:
|
|
||||||
return contract_numbers
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def is_empty_result(self) -> bool:
|
|
||||||
for selector in self.EMPTY_SELECTORS:
|
|
||||||
try:
|
|
||||||
if self.page.locator(selector).first.is_visible(timeout=2000):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
|
|
||||||
@allure.step("断言列表仅展示合同编号模糊匹配:{contract_no_keyword}")
|
|
||||||
def assert_contract_no_fuzzy_match(self, contract_no_keyword: str):
|
|
||||||
"""
|
|
||||||
优先按“合同编号”列断言;
|
|
||||||
如因页面侦察缺失无法识别列,则退化为断言每行文本均包含查询关键字。
|
|
||||||
"""
|
|
||||||
expect(self.page.locator("body")).to_be_visible()
|
|
||||||
|
|
||||||
contract_numbers = self.get_result_contract_numbers()
|
|
||||||
if contract_numbers:
|
|
||||||
assert all(contract_no_keyword in item for item in contract_numbers), (
|
|
||||||
f"存在合同编号不匹配查询条件:keyword={contract_no_keyword}, "
|
|
||||||
f"contract_numbers={contract_numbers}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
row_texts = self.get_result_rows_text()
|
|
||||||
if row_texts:
|
|
||||||
assert all(contract_no_keyword in row_text for row_text in row_texts), (
|
|
||||||
f"无法识别合同编号列,已退化为行文本校验;存在行不包含查询条件:"
|
|
||||||
f"keyword={contract_no_keyword}, rows={row_texts}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
assert self.is_empty_result(), "查询后列表无数据且未识别到空数据提示,请检查表格 selector 或查询条件"
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from playwright.sync_api import Page
|
|
||||||
|
|
||||||
from zhyy.test_case.Resource.UI.base_page import BasePage
|
|
||||||
|
|
||||||
|
|
||||||
class LoginPage(BasePage):
|
|
||||||
"""
|
|
||||||
登录页对象。
|
|
||||||
|
|
||||||
注意:
|
|
||||||
当前用例未提供登录页侦察结果,因此以下 selector 使用 TODO 占位。
|
|
||||||
落地执行前请通过页面侦察补充真实 selector。
|
|
||||||
"""
|
|
||||||
|
|
||||||
USERNAME_INPUT = "TODO_LOGIN_USERNAME_INPUT_SELECTOR"
|
|
||||||
PASSWORD_INPUT = "TODO_LOGIN_PASSWORD_INPUT_SELECTOR"
|
|
||||||
LOGIN_BUTTON = "TODO_LOGIN_BUTTON_SELECTOR"
|
|
||||||
|
|
||||||
def __init__(self, page: Page):
|
|
||||||
super().__init__(page)
|
|
||||||
|
|
||||||
def is_login_page(self) -> bool:
|
|
||||||
"""
|
|
||||||
判断当前是否处于登录页。
|
|
||||||
由于缺少真实登录页 selector,这里通过 TODO selector 判断。
|
|
||||||
若系统已通过 SSO 或已有登录态直接进入业务页,可返回 False。
|
|
||||||
"""
|
|
||||||
if "TODO" in self.USERNAME_INPUT:
|
|
||||||
# 未补充登录页定位时,不主动执行登录,避免误判。
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
return self.page.locator(self.USERNAME_INPUT).first.is_visible(timeout=3_000)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def login_if_needed(self, username: str, password: str):
|
|
||||||
"""
|
|
||||||
如当前页面需要登录,则执行登录。
|
|
||||||
"""
|
|
||||||
if not self.is_login_page():
|
|
||||||
return
|
|
||||||
|
|
||||||
self.fill(self.USERNAME_INPUT, username)
|
|
||||||
self.fill(self.PASSWORD_INPUT, password)
|
|
||||||
self.click(self.LOGIN_BUTTON)
|
|
||||||
self.wait_for_network_idle()
|
|
||||||