refactor: 优化 CoreDriver 实现并增强代码可读性

- 优化 部分核心功能实现。
- 新增 详细的文档字符串(Docstrings)和注释。
- 移除 代码中的冗余注释和无效代码。
This commit is contained in:
2026-01-22 15:39:20 +08:00
parent 1bcad0d166
commit 2e98252e34
5 changed files with 266 additions and 87 deletions

View File

@@ -7,11 +7,10 @@
@contact: t6g888@163.com,chenwei@zygj.com @contact: t6g888@163.com,chenwei@zygj.com
@file: driver @file: driver
@date: 2026/1/16 10:49 @date: 2026/1/16 10:49
@desc: @desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。
""" """
import logging import logging
from enum import Enum from typing import Optional, Type, TypeVar, Union, Callable
from typing import Optional, Type, TypeVar
from time import sleep from time import sleep
from appium import webdriver from appium import webdriver
@@ -19,10 +18,10 @@ from appium.options.android import UiAutomator2Options
from appium.options.ios import XCUITestOptions from appium.options.ios import XCUITestOptions
from appium.options.common.base import AppiumOptions from appium.options.common.base import AppiumOptions
from appium.webdriver.webdriver import ExtensionBase from appium.webdriver.webdriver import ExtensionBase
from appium.webdriver.webelement import WebElement
from appium.webdriver.client_config import AppiumClientConfig from appium.webdriver.client_config import AppiumClientConfig
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.common.bidi.cdp import session_context
from selenium.common import TimeoutException
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
@@ -31,35 +30,44 @@ from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver.common.actions.pointer_input import PointerInput from selenium.webdriver.common.actions.pointer_input import PointerInput
from utils.finder import by_converter from utils.finder import by_converter
from settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT from utils.decorators import resolve_wait_method
from core.modules import AppPlatform
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
class AppPlatform(Enum):
ANDROID = "android"
IOS = "ios"
class CoreDriver: class CoreDriver:
def __init__(self): def __init__(self):
"""
初始化 CoreDriver 实例。
从 settings.py 加载默认的 Appium 服务器主机和端口。
"""
self.driver: Optional[webdriver.Remote] = None self.driver: Optional[webdriver.Remote] = None
self._host = "127.0.0.1" self._host = APPIUM_HOST
self._port = 4723 self._port = APPIUM_PORT
def server_config(self, host: str = "127.0.0.1", port: int = 4723): def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT):
"""配置服务端信息"""
self._host = host self._host = host
self._port = port self._port = port
logger.info(f"Appium Server 指向 -> {self._host}:{self._port}") logger.info(f"Appium Server 指向 -> {self._host}:{self._port}")
return self
def connect(self, platform: str | AppPlatform, caps: dict, def connect(self, platform: str | AppPlatform, caps: dict,
extensions: list[Type[ExtensionBase]] | None = None, extensions: list[Type[ExtensionBase]] | None = None,
client_config: AppiumClientConfig | None = None) -> 'CoreDriver': client_config: AppiumClientConfig | None = None) -> 'CoreDriver':
""" """
参照 KeyWordDriver 逻辑,但强化了配置校验和异常处理 连接到 Appium 服务器并创建一个新的会话。
:param platform: 目标平台 ('android''ios'),支持 AppPlatform 枚举或字符串。
:param caps: Appium capabilities 字典。
:param extensions: Appium 驱动扩展列表。
:param client_config: Appium 客户端配置。
:return: 返回 CoreDriver 实例自身,支持链式调用。
:raises ValueError: 如果平台不受支持。
:raises ConnectionError: 如果无法连接到 Appium 服务。
""" """
# 1. 统一格式化平台名称 # 1. 统一格式化平台名称
platform_name = platform.value if isinstance(platform, AppPlatform) else platform.lower().strip() platform_name = platform.value if isinstance(platform, AppPlatform) else platform.lower().strip()
@@ -105,54 +113,77 @@ class CoreDriver:
raise ConnectionError(f"无法连接到 Appium 服务,请检查端口 {self._port} 或设备状态。") from e raise ConnectionError(f"无法连接到 Appium 服务,请检查端口 {self._port} 或设备状态。") from e
# --- 核心操作 --- # --- 核心操作 ---
def find_element(self, by, value, timeout=10): def find_element(self, by, value, timeout: Optional[float] = None) -> WebElement:
"""内部通用查找(显式等待)""" """
内部通用查找(显式等待)
:param by: 定位策略
:param value: 定位值
:param timeout: 等待超时时间 (秒)。如果为 None, 则使用全局默认超时.
:return: WebElement.
"""
by = by_converter(by) by = by_converter(by)
mark = (by, value) mark = (by, value)
wait_timeout = timeout if timeout is not None else EXPLICIT_WAIT_TIMEOUT
return WebDriverWait(self.driver, wait_timeout).until(EC.presence_of_element_located(mark))
return WebDriverWait(self.driver, timeout).until(EC.presence_of_element_located(mark)) def delay(self, timeout: int | float) -> 'CoreDriver':
"""
强制等待(线程阻塞)。
def delay(self, timeout: int | float): 应谨慎使用,主要用于等待非 UI 元素的异步操作或调试。
:param timeout: 等待时间(秒)。
:return: self
"""
sleep(timeout) sleep(timeout)
return self return self
def implicit_wait(self, timeout: float = IMPLICIT_WAIT_TIMEOUT, *args, **kwargs) -> None: def implicit_wait(self, timeout: float = IMPLICIT_WAIT_TIMEOUT) -> None:
""" """
隐式等待 设置全局隐式等待时间。
在每次 find_element 时生效,直到元素出现或超时。
:param timeout: 超时时间 :param timeout: 超时时间
:param args:
:param kwargs:
:return: :return:
""" """
self.driver.implicitly_wait(timeout) self.driver.implicitly_wait(timeout)
def explicit_wait(self, method: T, timeout: float = EXPLICIT_WAIT_TIMEOUT, *args, **kwargs): @resolve_wait_method
def explicit_wait(self, method: Union[Callable[[webdriver.Remote], T], str], timeout: Optional[float] = None) -> \
Union[T, WebElement]:
""" """
显示等待 执行显式等待,直到满足某个条件或超时。
:param method: 可调用对象名 :param method: EC等待条件(Callable) 或 自定义等待条件的名称(str)
:param timeout: 超时时间 :param timeout: 超时时间 (秒)。如果为 None, 则使用全局默认超时.
:param args: :return: 等待条件的执行结果 (通常是 WebElement 或 bool)
:param kwargs:
:return:
""" """
wait_timeout = timeout if timeout is not None else EXPLICIT_WAIT_TIMEOUT
try: try:
if isinstance(method, str): # 获取函数名称用于日志,兼容 lambda 和普通函数
# method = custom_ec.get(method, (lambda _: False)) func_name = getattr(method, '__name__', repr(method))
method = lambda _: False logger.info(f"执行显式等待: {func_name}, 超时: {wait_timeout}s")
return WebDriverWait(self.driver, wait_timeout).until(method)
logger.info(f"预期条件: {method.__name__}")
return WebDriverWait(self.driver, timeout).until(method)
except TypeError as te: except TypeError as te:
logger.error(f"显示等待异常: {te}") logger.error(f"显示等待异常: {te}")
# self.driver.quit() # self.driver.quit()
raise te raise te
def page_load_timeout(self, timeout: float, *args, **kwargs) -> None: def page_load_timeout(self, timeout: Optional[float] = None) -> None:
self.driver.set_page_load_timeout(timeout) """
设置页面加载超时时间。
:param timeout: 超时时间 (秒)。如果为 None, 则使用全局默认超时.
"""
wait_timeout = timeout if timeout is not None else EXPLICIT_WAIT_TIMEOUT
self.driver.set_page_load_timeout(wait_timeout)
def click(self, by, value, timeout=10) -> 'CoreDriver': def click(self, by, value, timeout: Optional[float] = None) -> 'CoreDriver':
"""
查找元素并执行点击操作。
内置显式等待,确保元素可点击。
:param by: 定位策略。
:param value: 定位值。
:param timeout: 等待超时时间。
:return: self
"""
by = by_converter(by) by = by_converter(by)
mark = (by, value) mark = (by, value)
logger.info(f"点击: {mark}") logger.info(f"点击: {mark}")
@@ -160,49 +191,104 @@ class CoreDriver:
self.explicit_wait(method, timeout).click() self.explicit_wait(method, timeout).click()
return self return self
def clear(self, by, value, *args, **kwargs): def clear(self, by, value, timeout: Optional[float] = None) -> 'CoreDriver':
"""
查找元素并清空其内容。
内置显式等待,确保元素可见。
:param by: 定位策略。
:param value: 定位值。
:param timeout: 等待超时时间。
:return: self
"""
by = by_converter(by) by = by_converter(by)
mark = (by, value) mark = (by, value)
logger.info(f"清空输入框: {mark}")
method = EC.visibility_of_element_located(mark) method = EC.visibility_of_element_located(mark)
self.explicit_wait(method, timeout).clear()
return self
self.explicit_wait(method).clear() def input(self, by, value, text, timeout: Optional[float] = None) -> 'CoreDriver':
"""
# self.driver.find_element(by, value).clear() 查找元素并输入文本。
内置显式等待,确保元素可见。
def input(self, by, value, text, timeout=10) -> 'CoreDriver': :param by: 定位策略。
:param value: 定位值。
:param text: 要输入的文本。
:param timeout: 等待超时时间。
:return: self
"""
by = by_converter(by) by = by_converter(by)
mark = (by, value) mark = (by, value)
logger.info(f"输入文本到 {mark}: '{text}'")
method = EC.visibility_of_element_located(mark) method = EC.visibility_of_element_located(mark)
self.explicit_wait(method, timeout).send_keys(text) self.explicit_wait(method, timeout).send_keys(text)
return self return self
def get_text(self, by, value, *args, **kwargs): def is_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool:
"""
判断元素是否可见
:param by: 定位策略。
:param value: 定位值。
:param timeout: 等待超时时间。
:return: bool
"""
try:
by = by_converter(by)
mark = (by, value)
method = EC.visibility_of_element_located(mark)
self.explicit_wait(method, timeout)
return True
except TimeoutException:
return False
def get_text(self, by, value, timeout: Optional[float] = None) -> str:
""" """
获取元素文本 获取元素文本
:param by: :param by: 定位策略。
:param value: :param value: 定位值。
:param args: :param timeout: 等待超时时间。
:param kwargs: :return:获取到的文本
:return:
""" """
by = by_converter(by) by = by_converter(by)
mark = (by, value) mark = (by, value)
method = EC.visibility_of_element_located(mark) method = EC.visibility_of_element_located(mark)
text = self.explicit_wait(method).text text = self.explicit_wait(method, timeout).text
logger.info(f"获取到的文本{text}") logger.info(f"获取到的文本{text}")
return text return text
def get_session_id(self): @property
def session_id(self):
"""获取当前 Appium 会话的 Session ID。"""
return self.driver.session_id return self.driver.session_id
# --- 移动端特有:方向滑动 --- # --- 移动端特有:方向滑动 ---
def swipe_to(self, direction: str = "up", duration: int = 1000) -> 'CoreDriver': def swipe_by_coordinates(self, start_x: int, start_y: int, end_x: int, end_y: int,
duration: int = 1000) -> 'CoreDriver':
""" """
封装方向滑动 (W3C Actions 兼容版) 基于绝对坐标的滑动 (W3C Actions 底层实现)
:param start_x: 起点 X
:param start_y: 起点 Y
:param end_x: 终点 X
:param end_y: 终点 Y
:param duration: 滑动持续时间 (ms)
:return: self
"""
actions = ActionChains(self.driver)
# 覆盖默认的鼠标输入为触摸输入
actions.w3c_actions = ActionBuilder(self.driver, mouse=PointerInput(interaction.POINTER_TOUCH, "touch"))
actions.w3c_actions.pointer_action.move_to_location(start_x, start_y)
actions.w3c_actions.pointer_action.pointer_down()
actions.w3c_actions.pointer_action.pause(duration / 1000)
actions.w3c_actions.pointer_action.move_to_location(end_x, end_y)
actions.w3c_actions.pointer_action.release()
actions.perform()
return self
def swipe(self, direction: str = "up", duration: int = 1000) -> 'CoreDriver':
"""
封装方向滑动
:param direction: 滑动方向 up/down/left/right :param direction: 滑动方向 up/down/left/right
:param duration: 滑动持续时间 (ms) :param duration: 滑动持续时间 (ms)
:return: self :return: self
@@ -211,43 +297,114 @@ class CoreDriver:
size = self.driver.get_window_size() size = self.driver.get_window_size()
w, h = size['width'], size['height'] w, h = size['width'], size['height']
# 定义滑动坐标 (避开边缘区域) # 定义滑动坐标 (中心区域,避开边缘,防止误触系统操作)
coords = { coords = {
"up": (w * 0.5, h * 0.8, w * 0.5, h * 0.2), "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), "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), "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) "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"]) start_x, start_y, end_x, end_y = coords.get(direction.lower(), coords["up"])
logger.info(f"执行滑动: {direction} ({start_x}, {start_y}) -> ({end_x}, {end_y})") logger.info(f"执行滑动: {direction} ({start_x}, {start_y}) -> ({end_x}, {end_y})")
# 使用 W3C ActionChains 替代已废弃的 driver.swipe return self.swipe_by_coordinates(start_x, start_y, end_x, end_y, duration)
actions = ActionChains(self.driver)
# 覆盖默认的鼠标输入为触摸输入 def long_press(self, element: Optional[WebElement] = None, x: Optional[int] = None, y: Optional[int] = None,
actions.w3c_actions = ActionBuilder(self.driver, mouse=PointerInput(interaction.POINTER_TOUCH, "touch")) duration: int = 2000) -> 'CoreDriver':
"""
actions.w3c_actions.pointer_action.move_to_location(start_x, start_y) 长按封装:支持传入元素或坐标
actions.w3c_actions.pointer_action.pointer_down() """
actions.w3c_actions.pointer_action.pause(duration / 1000) # pause 单位为秒 if element:
actions.w3c_actions.pointer_action.move_to_location(end_x, end_y) rect = element.rect
actions.w3c_actions.pointer_action.release() x = rect['x'] + rect['width'] // 2
actions.perform() y = rect['y'] + rect['height'] // 2
if x is None or y is None:
raise ValueError("Long press requires an element or (x, y) coordinates.")
# 复用 swipe_by_coordinates当起点和终点一致时即为长按效果。
# 逻辑Move -> Down -> Pause -> Move(原地) -> Release
return self.swipe_by_coordinates(x, y, x, y, duration)
def drag_and_drop(self, source_el: WebElement, target_el: WebElement, duration: int = 1000) -> 'CoreDriver':
"""
将 source_el 拖拽到 target_el
"""
s_rect = source_el.rect
t_rect = target_el.rect
sx, sy = s_rect['x'] + s_rect['width'] // 2, s_rect['y'] + s_rect['height'] // 2
tx, ty = t_rect['x'] + t_rect['width'] // 2, t_rect['y'] + t_rect['height'] // 2
logger.info(f"执行拖拽: ({sx}, {sy}) -> ({tx}, {ty})")
return self.swipe_by_coordinates(sx, sy, tx, ty, duration)
def smart_scroll(self, element: WebElement, direction: str = "down") -> 'CoreDriver':
"""
智能滚动:自动识别平台并调用最稳定的原生滚动脚本
:param element: 需要滚动的容器元素 (如 ScrollView, RecyclerView, TableView)
:param direction: 滚动方向 'up', 'down', 'left', 'right'
"""
platform = self.driver.capabilities.get('platformName', '').lower()
match platform:
case AppPlatform.ANDROID.value:
# Android UiAutomator2 专用滚动手势
self.driver.execute_script('mobile: scrollGesture', {
'elementId': element.id,
'direction': direction,
'percent': 1.0
})
case AppPlatform.IOS.value:
# iOS XCUITest 专用滚动 (默认为 iOS 处理)
self.driver.execute_script("mobile: scroll", {
"elementId": element.id,
"direction": direction
})
return self return self
def swipe_by_percent(self, start_xp, start_yp, end_xp, end_yp, duration: int = 1000) -> 'CoreDriver':
"""
按屏幕比例滑动 (0.5 = 50%)
"""
size = self.driver.get_window_size()
w, h = size['width'], size['height']
return self.swipe_by_coordinates(
int(w * start_xp),
int(h * start_yp),
int(w * end_xp),
int(h * end_yp),
duration
)
@property
def is_alive(self) -> bool:
"""判断当前驱动会话是否仍然存活。"""
return self.driver is not None and self.driver.session_id is not None
# --- 断言逻辑 --- # --- 断言逻辑 ---
def assert_text(self, by, value, expected_text) -> 'CoreDriver': def assert_text(self, by, value, expected_text, timeout: Optional[float] = None) -> 'CoreDriver':
actual = self.get_text(by, value) """
断言元素的文本内容是否符合预期。
:param by: 定位策略。
:param value: 定位值。
:param expected_text: 期望的文本。
:param timeout: 等待元素可见的超时时间。
:return: self支持链式调用。
:raises AssertionError: 如果文本不匹配。
"""
actual = self.get_text(by, value, timeout)
assert actual == expected_text, f"断言失败: 期望 {expected_text}, 实际 {actual}" assert actual == expected_text, f"断言失败: 期望 {expected_text}, 实际 {actual}"
logger.info(f"断言通过: 文本匹配 '{actual}'") logger.info(f"断言通过: 文本匹配 '{actual}'")
return self return self
def quit(self): def quit(self):
"""安全退出""" """安全关闭 Appium 驱动并断开连接。"""
if self.driver: if self.driver:
try: try:
# 获取 session_id 用于日志追踪 # 获取 session_id 用于日志追踪
sid = self.driver.session_id sid = self.session_id
self.driver.quit() self.driver.quit()
logger.info(f"已安全断开连接 (Session: {sid})") logger.info(f"已安全断开连接 (Session: {sid})")
except Exception as e: except Exception as e:
@@ -256,8 +413,3 @@ class CoreDriver:
self.driver = None self.driver = None
else: else:
logger.debug("没有正在运行的 Driver 实例需要关闭。") logger.debug("没有正在运行的 Driver 实例需要关闭。")
@property
def is_alive(self) -> bool:
"""判断当前驱动是否可用"""
return self.driver is not None and self.driver.session_id is not None

View File

@@ -12,6 +12,11 @@
from enum import Enum from enum import Enum
class AppPlatform(Enum):
ANDROID = "android"
IOS = "ios"
class Locator(str, Enum): class Locator(str, Enum):
# --- 原有 Selenium 支持 --- # --- 原有 Selenium 支持 ---
ID = "id" ID = "id"

View File

@@ -14,8 +14,9 @@ import os
from pathlib import Path from pathlib import Path
# 项目根目录 (core 的上一级) # 项目根目录 (core 的上一级)
BASE_DIR = Path(__file__).parent.parent # BASE_DIR = Path(__file__).parent.parent
BASE_DIR = Path(__file__).resolve().parents[1] # 获取根路径(绝对路径)
print(BASE_DIR)
# --- 目录配置 --- # --- 目录配置 ---
OUTPUT_DIR = BASE_DIR / "outputs" OUTPUT_DIR = BASE_DIR / "outputs"
LOG_DIR = OUTPUT_DIR / "logs" LOG_DIR = OUTPUT_DIR / "logs"

View File

@@ -8,7 +8,8 @@ import pytest
from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP, REPORT_DIR from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP, REPORT_DIR
# netstat -ano | findstr :4723
# taskkill /PID 12345 /F
# 日志自动清理 # 日志自动清理
def _clean_old_logs(backup_dir, keep_count=10): def _clean_old_logs(backup_dir, keep_count=10):
files = sorted(Path(backup_dir).glob("pytest_*.log"), key=os.path.getmtime) files = sorted(Path(backup_dir).glob("pytest_*.log"), key=os.path.getmtime)

20
utils/decorators.py Normal file
View File

@@ -0,0 +1,20 @@
import logging
from functools import wraps
from typing import Union, Callable
logger = logging.getLogger(__name__)
def resolve_wait_method(func):
"""
装饰器:将字符串形式的等待条件解析为可调用的 EC 对象
"""
@wraps(func)
def wrapper(self, method: Union[Callable, str], *args, **kwargs):
if isinstance(method, str):
# TODO: 这里可以接入 custom_ec 字典进行查找
# method = custom_ec.get(method, lambda x: False)
logger.info(f"解析命名等待条件: '{method}'")
# 保持原有逻辑作为占位
method = lambda _: False
return func(self, method, *args, **kwargs)
return wrapper