diff --git a/conftest.py b/conftest.py index c1be9f9..3de9641 100644 --- a/conftest.py +++ b/conftest.py @@ -13,7 +13,7 @@ import logging import pytest from core.run_appium import start_appium_service, stop_appium_service -from core.driver import AppDriver +from core.driver import CoreDriver @pytest.fixture(scope="session") @@ -36,7 +36,7 @@ def driver(app_server): 依赖 app_server,确保服务 Ready 后才创建连接。 """ # 实例化你提供的类结构 - app_helper = AppDriver() + app_helper = CoreDriver() # 配置Android设备参数 capabilities = dict( diff --git a/core/driver.py b/core/driver.py index 385f3c9..7225e2d 100644 --- a/core/driver.py +++ b/core/driver.py @@ -19,7 +19,12 @@ from appium.options.ios import XCUITestOptions from appium.options.common.base import AppiumOptions from appium.webdriver.webdriver import ExtensionBase from appium.webdriver.client_config import AppiumClientConfig +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from utils.finder import by_converter logger = logging.getLogger(__name__) @@ -28,21 +33,22 @@ class AppPlatform(Enum): IOS = "ios" -class AppDriver: + +class CoreDriver: def __init__(self): self.driver: Optional[webdriver.Remote] = None self._host = "127.0.0.1" self._port = 4723 - def server_config(self): + def server_config(self, host: str = "127.0.0.1", port: int = 4723): """配置服务端信息""" - # self._host = host - # self._port = port + self._host = host + self._port = port logger.info(f"Appium Server 指向 -> {self._host}:{self._port}") def connect(self, platform: str | AppPlatform, caps: dict, extensions: list[Type[ExtensionBase]] | None = None, - client_config: AppiumClientConfig | None = None) -> 'AppDriver': + client_config: AppiumClientConfig | None = None) -> 'CoreDriver': """ 参照 KeyWordDriver 逻辑,但强化了配置校验和异常处理 """ @@ -89,18 +95,53 @@ class AppDriver: self.driver = None raise ConnectionError(f"无法连接到 Appium 服务,请检查端口 {self._port} 或设备状态。") from e - # --- 开始封装操作方法 --- - def click(self, locator: tuple): - """封装点击,locator 格式如 (AppiumBy.ID, "xxx")""" - logger.info(f"点击元素: {locator}") - self.driver.find_element(*locator).click() - def input(self, locator: tuple, text: str): - """封装输入""" - logger.info(f"对元素 {locator} 输入内容: {text}") - el = self.driver.find_element(*locator) + # --- 核心操作 --- + def find(self, by, value, timeout=10): + """内部通用查找(显式等待)""" + by = by_converter(by) + target = (by, value) + # self.driver.find_element() + return WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(target)) + + def click(self, by, value, timeout=10) -> 'CoreDriver': + target = (by_converter(by), value) + logger.info(f"点击: {target}") + WebDriverWait(self.driver, timeout).until(EC.element_to_be_clickable(target)).click() + return self + + def input(self, by, value, text, timeout=10) -> 'CoreDriver': + target = (by_converter(by), value) + logger.info(f"输入 '{text}' 到: {target}") + el = WebDriverWait(self.driver, timeout).until(EC.visibility_of_element_located(target)) el.clear() el.send_keys(text) + return self + + # --- 移动端特有:方向滑动 --- + def swipe_to(self, direction: str = "up", duration: int = 800) -> 'CoreDriver': + """封装方向滑动,无需计算具体坐标""" + if not self._size: + self._size = self.driver.get_window_size() + + w, h = self._size['width'], self._size['height'] + # 这里的 0.8/0.2 比例是为了避开刘海屏和虚拟按键,提高滑动成功率 + coords = { + "up": (w * 0.5, h * 0.8, w * 0.5, h * 0.2), + "down": (w * 0.5, h * 0.2, w * 0.5, h * 0.8), + "left": (w * 0.9, h * 0.5, w * 0.1, h * 0.5), + "right": (w * 0.1, h * 0.5, w * 0.9, h * 0.5) + } + start_x, start_y, end_x, end_y = coords.get(direction.lower(), coords["up"]) + self.driver.swipe(start_x, start_y, end_x, end_y, duration) + return self + + # --- 断言逻辑 --- + def assert_text(self, by, value, expected_text) -> 'CoreDriver': + actual = self.find(by, value).text + assert actual == expected_text, f"断言失败: 期望 {expected_text}, 实际 {actual}" + logger.info(f"断言通过: 文本匹配 '{actual}'") + return self diff --git a/core/modules.py b/core/modules.py new file mode 100644 index 0000000..10dc092 --- /dev/null +++ b/core/modules.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# coding=utf-8 + +""" +@author: CNWei,ChenWei +@Software: PyCharm +@contact: t6g888@163.com,chenwei@zygj.com +@file: modules +@date: 2026/1/20 11:54 +@desc: +""" + +from enum import Enum + +class Locator(str, Enum): + # --- 原有 Selenium 支持 --- + ID = "id" + NAME = "name" + CLASS = "class" + TAG = "tag" + LINK_TEXT = "link_text" + PARTIAL_LINK_TEXT = "partial_link_text" + CSS = "css" + XPATH = "xpath" + # --- Appium 特有支持 --- + ACCESSIBILITY_ID = "accessibility_id" + AID = "aid" # 简写 + ANDROID_UIAUTOMATOR = "android_uiautomator" + IOS_PREDICATE = "ios_predicate" \ No newline at end of file diff --git a/main.py b/main.py index 0dce591..26fb709 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,38 @@ import os import shutil +import subprocess import datetime from pathlib import Path import pytest from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP, REPORT_DIR + + # 日志自动清理 def _clean_old_logs(backup_dir, keep_count=10): files = sorted(Path(backup_dir).glob("pytest_*.log"), key=os.path.getmtime) while len(files) > keep_count: - os.remove(files.pop(0)) + file_to_remove = files.pop(0) + try: + os.remove(file_to_remove) + except OSError as e: + print(f"清理旧日志失败 {file_to_remove}: {e}") def main(): try: # 2. 执行 Pytest # 建议保留你之前配置的 -s -v 等参数 - exit_code = pytest.main(["test_cases", "-x", "-v", f"--alluredir={ALLURE_TEMP}"]) + # 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x + pytest.main(["test_cases", "-x", "-v", f"--alluredir={ALLURE_TEMP}"]) # 3. 生成报告 if ALLURE_TEMP.exists(): - os.system(f'allure generate {ALLURE_TEMP} -o {REPORT_DIR} --clean') + # 使用 subprocess 替代 os.system,更安全且跨平台兼容性更好 + subprocess.run(f'allure generate {ALLURE_TEMP} -o {REPORT_DIR} --clean', shell=True, check=False) + except Exception as e: + print(f"自动化测试执行过程中发生异常: {e}") finally: # 4. 备份日志 (无论测试是否崩溃都执行) diff --git a/pytest.ini b/pytest.ini index 5b87806..43cfca2 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] addopts = -q --show-capture=no -;addopts = --tb=short + # 1. 开启实时控制台日志 log_cli = True log_cli_level = INFO diff --git a/utils/finder.py b/utils/finder.py new file mode 100644 index 0000000..e1e7cda --- /dev/null +++ b/utils/finder.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python +# coding=utf-8 + +""" +@author: CNWei,ChenWei +@Software: PyCharm +@contact: t6g888@163.com,chenwei@zygj.com +@file: locator_utils +@date: 2026/1/20 15:40 +@desc: +""" +from typing import Literal, Final +from appium.webdriver.common.appiumby import AppiumBy + +ByType = Literal[ + # By(selenium) + "id", "xpath", "link text", "partial link text", "name", "tag name", "class name", "css selector", + # AppiumBy + '-ios predicate string', + '-ios class chain', + '-android uiautomator', + '-android viewtag', + '-android datamatcher', + '-android viewmatcher', + 'accessibility id', + '-image', + '-custom', + '-flutter semantics label', + '-flutter type', + '-flutter key', + '-flutter text', + '-flutter text containing', + # 自定义常用简写 (Shortcuts) + "aid", "class", "css", "uiautomator", "predicate", "chain", +] + +class FinderConverter: + """ + 定位查找转换工具类 + 提供策略的归一化处理、简写映射及动态自定义注册 + """ + + # 预设的常用简写 + _BUILTIN_SHORTCUTS: Final = { + "aid": AppiumBy.ACCESSIBILITY_ID, + "class": AppiumBy.CLASS_NAME, + "css": AppiumBy.CSS_SELECTOR, + "uiautomator": AppiumBy.ANDROID_UIAUTOMATOR, + "predicate": AppiumBy.IOS_PREDICATE, + "chain": AppiumBy.IOS_CLASS_CHAIN, + } + + def __init__(self): + self._finder_map: dict[str, str] = {} + self._map_cache: dict[str, str] = {} + self._initialize() + + @staticmethod + def _normalize(text: str) -> str: + """ + 统一清洗逻辑:转小写、去除空格、下划线、横杠 + """ + if not isinstance(text, str): + raise TypeError(f"Locator strategy must be a string, got {type(text).__name__} instead.") + return text.lower().strip().replace('_', '').replace(' ', '').replace('-', '') + + def _initialize(self) -> None: + """初始化基础映射表""" + # 1. 动态加载 AppiumBy 常量值 + for attr_name in dir(AppiumBy): + if attr_name.startswith("_"): + continue + + attr_value = getattr(AppiumBy, attr_name) + if isinstance(attr_value, str): + # "class name" -> classname,"class_name" -> classname + self._finder_map[self._normalize(attr_value)] = attr_value + + # 2. 加载内置简写(会覆盖同名的策略) + self._finder_map.update(self._BUILTIN_SHORTCUTS) + + # 3. 备份初始状态 + self._map_cache = self._finder_map.copy() + + def convert(self, by_value: ByType | str) -> str: + """ + 将模糊或简写的定位方式转换为 Appium 标准定位字符串 + :raises ValueError: 当定位方式不支持时抛出 + """ + if not by_value or not isinstance(by_value, str): + raise ValueError(f"Invalid selector type: {type(by_value)}. Expected a string.") + + clean_key = self._normalize(by_value) + target = self._finder_map.get(clean_key) + + if target is None: + raise ValueError(f"Unsupported locator strategy: '{by_value}'.") + return target + + def register_custom_finder(self, alias: str, target: str) -> None: + """注册自定义定位策略""" + self._finder_map[self._normalize(alias)] = target + + def clear_custom_finders(self) -> None: + """重置回初始官方/内置状态""" + self._finder_map = self._map_cache.copy() + + def get_all_finders(self) -> list[str]: + """返回当前所有支持的策略 key(用于调试)""" + return list(self._finder_map.keys()) + + +# 导出单例,方便直接使用 +converter = FinderConverter() +by_converter = converter.convert +register_custom_finder = converter.register_custom_finder + +__all__=["by_converter", "register_custom_finder"] + +if __name__ == '__main__': + # 1. 测试标准转换与内置简写 + print(f"ID 转换: {by_converter('id')}") # 输出: id + print(f"AID 简写转换: {by_converter('aid')}") # 输出: accessibility id + print(f"CSS 简写转换: {by_converter('css')}") # 输出: css selector + + # 2. 测试强大的归一化容错 (空格、下划线、横杠、大小写) + print(f"类链容错: {by_converter(' -Ios-Class-Chain ')}") # 输出: -ios class chain + print(f"UIAutomator 容错: {by_converter('UI_AUTOMATOR')}") # 输出: -android uiautomator + + # 3. 测试自定义注册 + register_custom_finder("my_text", "-android uiautomator") + print(f"自定义注册测试: {by_converter('my_text')}") # 输出: -android uiautomator + + # 4. 测试重置功能 + converter.clear_custom_finders() + print("已重置自定义查找器") + try: + by_converter("my_text") + except ValueError as e: + print(f"验证重置成功 (捕获异常): {e}") + + # 5. 查看当前全量支持的归一化后的 Key + print(f"当前支持的策略总数: {len(converter.get_all_finders())}") + print(f"前 5 个策略示例: {converter.get_all_finders()[:5]}") + # 6. 增加类型非法测试 + print("\n--- 异常类型测试 ---") + try: + by_converter(123) # 传入数字 + except TypeError as e: + print(f"验证类型拦截成功: {e}") + + try: + by_converter(None) # 传入 None + except TypeError as e: + print(f"验证空值拦截成功: {e}") \ No newline at end of file