diff --git a/base_framework/platform_tools/Create_ui_testcase/generate_automation_api.py b/base_framework/platform_tools/Create_ui_testcase/generate_automation_api.py index f0ef66c..19f401e 100644 --- a/base_framework/platform_tools/Create_ui_testcase/generate_automation_api.py +++ b/base_framework/platform_tools/Create_ui_testcase/generate_automation_api.py @@ -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) } diff --git a/base_framework/platform_tools/Create_ui_testcase/server.py b/base_framework/platform_tools/Create_ui_testcase/server.py index 0075a0e..ab8db9c 100644 --- a/base_framework/platform_tools/Create_ui_testcase/server.py +++ b/base_framework/platform_tools/Create_ui_testcase/server.py @@ -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) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7e66c71 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "smart-management-auto-test", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/zhyy/screenshots/TC-zhyy-xsxt-hngl-001.png b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001.png new file mode 100644 index 0000000..47915d4 Binary files /dev/null and b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001.png differ diff --git a/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_01_open_page.png b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_01_open_page.png new file mode 100644 index 0000000..3374e0d Binary files /dev/null and b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_01_open_page.png differ diff --git a/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_02_after_login.png b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_02_after_login.png new file mode 100644 index 0000000..ca36ee9 Binary files /dev/null and b/zhyy/screenshots/TC-zhyy-xsxt-hngl-001_02_after_login.png differ diff --git a/zhyy/test_case/Resource/UI/__init__.py b/zhyy/test_case/Resource/UI/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zhyy/test_case/Resource/UI/base_page.py b/zhyy/test_case/Resource/UI/base_page.py new file mode 100644 index 0000000..50bdf62 --- /dev/null +++ b/zhyy/test_case/Resource/UI/base_page.py @@ -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) diff --git a/zhyy/test_case/Resource/UI/contract_manage_page.py b/zhyy/test_case/Resource/UI/contract_manage_page.py new file mode 100644 index 0000000..fcb1af6 --- /dev/null +++ b/zhyy/test_case/Resource/UI/contract_manage_page.py @@ -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}" + ) diff --git a/zhyy/test_case/Resource/UI/contract_management_page.py b/zhyy/test_case/Resource/UI/contract_management_page.py new file mode 100644 index 0000000..2ef86ee --- /dev/null +++ b/zhyy/test_case/Resource/UI/contract_management_page.py @@ -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}" + ) diff --git a/zhyy/test_case/Resource/UI/hngl_page.py b/zhyy/test_case/Resource/UI/hngl_page.py new file mode 100644 index 0000000..967cc20 --- /dev/null +++ b/zhyy/test_case/Resource/UI/hngl_page.py @@ -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 或查询条件" diff --git a/zhyy/test_case/Resource/UI/login_page.py b/zhyy/test_case/Resource/UI/login_page.py new file mode 100644 index 0000000..08ff67d --- /dev/null +++ b/zhyy/test_case/Resource/UI/login_page.py @@ -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() diff --git a/zhyy/test_case/Resource/__init__.py b/zhyy/test_case/Resource/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zhyy/test_case/TestCase/UI/test_TC_zhyy_ui_api_verify_001.py b/zhyy/test_case/TestCase/UI/test_TC_zhyy_ui_api_verify_001.py deleted file mode 100644 index 9dba19a..0000000 --- a/zhyy/test_case/TestCase/UI/test_TC_zhyy_ui_api_verify_001.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding:utf-8 -*- -import pytest - - -def test_tc_zhyy_ui_api_verify_001(): - assert "智慧运营"