Update UI automation test files

This commit is contained in:
2026-05-19 18:04:34 +08:00
parent e0e22b895e
commit 6d7dba25d8
14 changed files with 1076 additions and 52 deletions

View File

@@ -737,19 +737,64 @@ def _build_import_lines(project_code, page_file_name, class_names):
return import_lines
def _normalize_generated_imports(code, project_code, resource_files, remove_local_resource_imports=False):
resource_modules = [
os.path.splitext(resource_file.get("fileName", ""))[0]
for resource_file in resource_files
if resource_file.get("fileName", "").endswith(".py")
]
normalized_code = str(code or "")
normalized_code = re.sub(r"^from\s+pages\.[^\n]+\n", "", normalized_code, flags=re.MULTILINE)
for module_name in resource_modules:
if not module_name:
continue
if remove_local_resource_imports:
normalized_code = re.sub(
r"^from\s+{0}\s+import\s+[^\n]+\n".format(re.escape(module_name)),
"",
normalized_code,
flags=re.MULTILINE
)
else:
normalized_code = re.sub(
r"^from\s+{0}\s+import\s+([^\n]+)".format(re.escape(module_name)),
r"from {0}.test_case.Resource.UI.{1} import \1".format(project_code, module_name),
normalized_code,
flags=re.MULTILINE
)
return normalized_code.strip() + "\n"
def _python_string_literal(value):
return json.dumps(str(value or ""), ensure_ascii=False)
def _prepend_import_lines_preserving_future(code, import_lines):
unique_import_lines = list(dict.fromkeys(line for line in import_lines if line))
if not unique_import_lines:
return str(code or "").strip() + "\n"
lines = str(code or "").strip().splitlines()
future_lines = []
while lines and lines[0].startswith("from __future__ import "):
future_lines.append(lines.pop(0))
if lines and not lines[0].strip():
lines.pop(0)
result_lines = []
result_lines.extend(future_lines)
result_lines.extend(unique_import_lines)
result_lines.append("")
result_lines.extend(lines)
return "\n".join(result_lines).strip() + "\n"
def _ensure_allure_markers(testcase_code, module_name=None, case_name=None, project_name=None):
code = testcase_code.strip() + "\n"
if "import allure" not in code:
import_match = list(re.finditer(r"^(?:import\s+[^\n]+|from\s+[^\n]+\s+import\s+[^\n]+)$", code, re.MULTILINE))
if import_match:
insert_at = import_match[-1].end()
code = code[:insert_at] + "\nimport allure" + code[insert_at:]
else:
code = "import allure\n" + code
code = _prepend_import_lines_preserving_future(code, ["import allure"])
decorators = []
if project_name and "@allure.feature" not in code:
@@ -770,6 +815,19 @@ def _ensure_allure_markers(testcase_code, module_name=None, case_name=None, proj
return code.strip() + "\n"
def _normalize_missing_secret_asserts(testcase_code):
code = str(testcase_code or "")
if "pytest.skip" in code:
return code.strip() + "\n"
code = re.sub(
r"^(\s*)assert\s+([^\n,]+(?:password|PASSWORD|token|TOKEN|secret|SECRET)[^\n,]*),\s*([^\n]+)$",
r"\1if not \2:\n\1 pytest.skip(\3)",
code,
flags=re.MULTILINE
)
return code.strip() + "\n"
def split_ui_generated_code(generated_content, product_name, project_name, case_key, module_name=None):
explicit_blocks = extract_python_file_blocks(generated_content)
project_code = _resolve_project_code_or_default(product_name, project_name, case_key)
@@ -799,9 +857,26 @@ def split_ui_generated_code(generated_content, product_name, project_name, case_
class_name
))
test_code = "\n\n".join(test_code_parts).strip() + "\n"
test_code = re.sub(r"^from\s+pages\.[^\n]+\n", "", test_code, flags=re.MULTILINE)
test_code = _normalize_generated_imports(
code=test_code,
project_code=project_code,
resource_files=resource_files,
remove_local_resource_imports=True
)
if import_lines:
test_code = "\n".join(dict.fromkeys(import_lines)) + "\n" + test_code
test_code = _prepend_import_lines_preserving_future(test_code, import_lines)
resource_files = [
{
"fileName": resource_file["fileName"],
"code": _normalize_generated_imports(
code=resource_file["code"],
project_code=project_code,
resource_files=resource_files,
remove_local_resource_imports=False
)
}
for resource_file in resource_files
]
return test_code, resource_files
full_code = extract_python_code(generated_content)
@@ -827,7 +902,7 @@ def split_ui_generated_code(generated_content, product_name, project_name, case_
class_names.extend(_extract_defined_class_names(block))
import_lines = _build_import_lines(project_code, page_file_name, class_names)
if import_lines:
test_code = "\n".join(import_lines) + "\n" + test_code
test_code = _prepend_import_lines_preserving_future(test_code, import_lines)
test_code = re.sub(r"^from\s+pages\.[^\n]+\n", "", test_code, flags=re.MULTILINE)
return test_code.strip() + "\n", resource_files
@@ -881,6 +956,7 @@ def save_generated_ui_testcase(generated_content,
case_name=case_name or case_key,
project_name=project_name
)
testcase_code = _normalize_missing_secret_asserts(testcase_code)
with open(file_path, "w", encoding="utf-8") as file:
file.write(testcase_code)
@@ -888,6 +964,58 @@ def save_generated_ui_testcase(generated_content,
return file_path
def _classify_pytest_result(return_code, output):
output_text = str(output or "")
if return_code == -1:
return "timeout"
if return_code != 0:
return "failed"
if re.search(r"\b\d+\s+skipped\b", output_text, re.IGNORECASE):
return "skipped"
return "passed"
def _build_verification_message(status, failure_reason=None):
if status == "passed":
return "用例已生成并执行通过"
if status == "skipped":
return "用例已生成,但 pytest 执行被跳过,请检查环境变量、测试数据或跳过条件"
if status == "timeout":
return "用例已生成,但 pytest 执行超时"
return failure_reason or "用例已生成,但 pytest 执行失败"
def analyze_generated_case_quality(testcase_path, product_name, project_name, case_key):
files = read_generated_case_files(
testcase_path=testcase_path,
product_name=product_name,
project_name=project_name,
case_key=case_key,
only_imported_resources=True
)
todo_items = []
for relative_path, content in files.items():
for line_number, line in enumerate(str(content or "").splitlines(), 1):
if "TODO" in line or "PLACEHOLDER" in line:
todo_items.append({
"file": relative_path,
"line": line_number,
"text": line.strip()[:200]
})
warnings = []
if todo_items:
warnings.append("生成文件包含 TODO/PLACEHOLDER需要人工补充页面元素定位或测试数据")
return {
"hasTodoSelectors": bool(todo_items),
"todoCount": len(todo_items),
"requiresManualCompletion": bool(todo_items),
"warnings": warnings,
"todoItems": todo_items[:20]
}
def run_generated_ui_testcase(testcase_path, timeout=180):
command = [
sys.executable,
@@ -908,12 +1036,16 @@ def run_generated_ui_testcase(testcase_path, timeout=180):
stdout = process.stdout or ""
stderr = process.stderr or ""
output = (stdout + "\n" + stderr).strip()
verification_status = _classify_pytest_result(process.returncode, output)
failure_reason = summarize_test_failure(output)
return {
"success": process.returncode == 0,
"returnCode": process.returncode,
"stdout": stdout,
"stderr": stderr,
"failureReason": summarize_test_failure(output)
"verificationStatus": verification_status,
"verificationMessage": _build_verification_message(verification_status, failure_reason),
"failureReason": failure_reason
}
except subprocess.TimeoutExpired as error:
output = ((error.stdout or "") + "\n" + (error.stderr or "")).strip()
@@ -925,15 +1057,20 @@ def run_generated_ui_testcase(testcase_path, timeout=180):
"returnCode": -1,
"stdout": error.stdout or "",
"stderr": error.stderr or "",
"verificationStatus": "timeout",
"verificationMessage": _build_verification_message("timeout", failure_reason),
"failureReason": failure_reason
}
except Exception as error:
failure_reason = "pytest执行异常{0}".format(str(error))
return {
"success": False,
"returnCode": -1,
"stdout": "",
"stderr": str(error),
"failureReason": "pytest执行异常{0}".format(str(error))
"verificationStatus": "failed",
"verificationMessage": _build_verification_message("failed", failure_reason),
"failureReason": failure_reason
}
@@ -959,18 +1096,35 @@ def summarize_test_failure(output, max_length=3000):
return summary[-max_length:]
def read_generated_case_files(testcase_path, product_name, project_name, case_key):
def _extract_imported_resource_files(testcase_path, project_code):
if not os.path.exists(testcase_path):
return []
with open(testcase_path, "r", encoding="utf-8") as file:
content = file.read()
modules = re.findall(
r"^from\s+{0}\.test_case\.Resource\.UI\.([A-Za-z_][A-Za-z0-9_]*)\s+import\s+".format(re.escape(project_code)),
content,
flags=re.MULTILINE
)
return ["{0}.py".format(module_name) for module_name in modules]
def read_generated_case_files(testcase_path, product_name, project_name, case_key, only_imported_resources=False):
files = {}
candidate_paths = [testcase_path]
project_code = _resolve_project_code_or_default(product_name, project_name, case_key)
resource_dir = _resolve_ui_resource_dir(
product_name=product_name,
project_name=project_name,
case_key=case_key
)
if os.path.isdir(resource_dir):
for file_name in os.listdir(resource_dir):
if file_name.endswith(".py"):
candidate_paths.append(os.path.join(resource_dir, file_name))
if only_imported_resources:
resource_file_names = _extract_imported_resource_files(testcase_path, project_code)
else:
resource_file_names = [file_name for file_name in os.listdir(resource_dir) if file_name.endswith(".py")]
for file_name in resource_file_names:
candidate_paths.append(os.path.join(resource_dir, file_name))
for path in candidate_paths:
if os.path.exists(path):
@@ -1119,10 +1273,18 @@ def generate_save_and_verify_ui_testcase(project_id,
last_failure_reason = None
for attempt_index in range(1, max_attempts + 1):
run_result = run_generated_ui_testcase(testcase_path)
quality = analyze_generated_case_quality(
testcase_path=testcase_path,
product_name=product_name,
project_name=project_name,
case_key=case_key
)
attempts.append({
"attempt": attempt_index,
"success": run_result["success"],
"returnCode": run_result["returnCode"],
"verificationStatus": run_result.get("verificationStatus"),
"verificationMessage": run_result.get("verificationMessage"),
"failureReason": run_result["failureReason"]
})
if run_result["success"]:
@@ -1130,6 +1292,9 @@ def generate_save_and_verify_ui_testcase(project_id,
"success": True,
"testcasePath": testcase_path,
"attempts": attempts,
"quality": quality,
"verificationStatus": run_result.get("verificationStatus"),
"verificationMessage": run_result.get("verificationMessage"),
"failureReason": None
}
@@ -1164,10 +1329,19 @@ def generate_save_and_verify_ui_testcase(project_id,
case_name=case_name
)
quality = analyze_generated_case_quality(
testcase_path=testcase_path,
product_name=product_name,
project_name=project_name,
case_key=case_key
)
return {
"success": False,
"testcasePath": testcase_path,
"attempts": attempts,
"quality": quality,
"verificationStatus": attempts[-1].get("verificationStatus") if attempts else "failed",
"verificationMessage": attempts[-1].get("verificationMessage") if attempts else "用例生成后未执行",
"failureReason": "用例生成后执行并自动修复 {0} 次仍失败:\n{1}".format(max_attempts, last_failure_reason)
}

View File

@@ -1,7 +1,6 @@
# -*- coding:utf-8 -*-
import argparse
import json
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
@@ -46,52 +45,42 @@ class GenerateAutomationHandler(BaseHTTPRequestHandler):
})
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=request_data.get("prompt"),
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=request_data.get("steps"),
expected_results=request_data.get("expectedResults"),
steps=normalized_steps,
expected_results=normalized_expected_results,
case_name=self._get_case_name(request_data),
enable_reconnaissance=request_data.get("enableReconnaissance", True),
enable_reconnaissance=self._should_enable_reconnaissance(request_data, generation_mode),
headless=request_data.get("headless", True),
max_attempts=request_data.get("maxAttempts", 3)
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": 1,
"message": "用例生成后执行失败,自动修复{0}次仍未通过".format(request_data.get("maxAttempts", 3)),
"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),
"testcasePath": verify_result.get("testcasePath"),
"attempts": verify_result.get("attempts"),
"failureReason": verify_result.get("failureReason")
}
"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": "success",
"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),
"testcasePath": verify_result.get("testcasePath"),
"attempts": verify_result.get("attempts")
}
"message": verify_result.get("verificationMessage") or "success",
"data": response_data
})
except ValueError as error:
self._send_json_response(400, {
@@ -102,9 +91,8 @@ class GenerateAutomationHandler(BaseHTTPRequestHandler):
except Exception as error:
self._send_json_response(500, {
"code": 500,
"message": str(error),
"data": None,
"trace": traceback.format_exc()
"message": self._sanitize_error_message(str(error)),
"data": None
})
def _read_json_body(self):
@@ -147,6 +135,67 @@ class GenerateAutomationHandler(BaseHTTPRequestHandler):
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)

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "smart-management-auto-test",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

View File

@@ -0,0 +1,78 @@
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)

View File

@@ -0,0 +1,194 @@
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}"
)

View File

@@ -0,0 +1,208 @@
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}"
)

View File

@@ -0,0 +1,274 @@
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 或查询条件"

View File

@@ -0,0 +1,47 @@
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()

View File

View File

@@ -1,6 +0,0 @@
# -*- coding:utf-8 -*-
import pytest
def test_tc_zhyy_ui_api_verify_001():
assert "智慧运营"